From 4598b435c298fba83850bd6773aaa93afae5c601 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sun, 26 Jul 2026 21:57:14 +0200 Subject: [PATCH 01/86] Phase 1f-1g: size string results from miniexpr, not from NumPy miniexpr can now return strings, but the output container is allocated before evaluation, so it has to be sized from miniexpr's own width inference. Sizing it from numpy's result_type made the kernel silently truncate: ` --- src/blosc2/blosc2_ext.pyx | 110 ++++++++++++++++++++++++++++ src/blosc2/lazyexpr.py | 40 ++++++++++ src/blosc2/ndarray.py | 4 +- tests/ndarray/test_string_output.py | 101 +++++++++++++++++++++++++ 4 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 tests/ndarray/test_string_output.py diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index ed325a565..18e07e1e5 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -697,6 +697,9 @@ cdef extern from "miniexpr.h": int ncode void *parameters[1] + int me_compile(const char *expression, const me_variable *variables, + int var_count, me_dtype dtype, int *error, me_expr **out) + int me_compile_nd_jit(const char *expression, const me_variable *variables, int var_count, me_dtype dtype, int ndims, const int64_t *shape, const int32_t *chunkshape, @@ -740,6 +743,9 @@ cdef extern from "miniexpr.h": int me_nd_valid_nitems(const me_expr *expr, int64_t nchunk, int64_t nblock, int64_t *valid_nitems) nogil + me_dtype me_get_dtype(const me_expr *expr) nogil + size_t me_get_itemsize(const me_expr *expr) nogil + void me_print(const me_expr *n) nogil void me_free(me_expr *n) nogil @@ -904,6 +910,110 @@ cdef inline me_dtype _me_dtype_from_numpy_dtype(dtype_obj): return -1 +cdef inline object _numpy_dtype_from_me_dtype(me_dtype dt): + if dt == ME_BOOL: + return np.dtype(np.bool_) + if dt == ME_INT8: + return np.dtype(np.int8) + if dt == ME_INT16: + return np.dtype(np.int16) + if dt == ME_INT32: + return np.dtype(np.int32) + if dt == ME_INT64: + return np.dtype(np.int64) + if dt == ME_UINT8: + return np.dtype(np.uint8) + if dt == ME_UINT16: + return np.dtype(np.uint16) + if dt == ME_UINT32: + return np.dtype(np.uint32) + if dt == ME_UINT64: + return np.dtype(np.uint64) + if dt == ME_FLOAT32: + return np.dtype(np.float32) + if dt == ME_FLOAT64: + return np.dtype(np.float64) + if dt == ME_COMPLEX64: + return np.dtype(np.complex64) + if dt == ME_COMPLEX128: + return np.dtype(np.complex128) + return None + + +def me_output_dtype(expression, operands): + """Ask miniexpr what dtype *expression* would produce over *operands*. + + ``operands`` maps operand name -> numpy dtype. Compiles with ME_AUTO, reads + the inferred result back, and throws the program away. python-blosc2 needs + this before evaluating, because the output container must be allocated with a + fixed itemsize and string widths are known only to miniexpr's own inference + (e.g. ` ` 0: + variables = malloc(sizeof(me_variable) * n) + if variables == NULL: + raise MemoryError() + + try: + for k, v in operands.items(): + var = &variables[built] + operand_dtype = np.dtype(v) + try: + var.dtype = _me_dtype_from_numpy_dtype(operand_dtype) + except TypeError: + return None + if var.dtype < 0: + return None + var_name = k.encode("utf-8") if isinstance(k, str) else k + var.name = malloc(strlen(var_name) + 1) + strcpy(var.name, var_name) + var.address = NULL + var.type = 0 + var.context = NULL + var.itemsize = operand_dtype.itemsize if operand_dtype.num == 19 else 0 + built += 1 + + expression_bytes = ( + (expression).encode("utf-8") if isinstance(expression, str) else expression + ) + rc = me_compile(expression_bytes, variables, n, ME_AUTO, &error, &out_expr) + if rc != ME_COMPILE_SUCCESS or out_expr == NULL: + if out_expr != NULL: + me_free(out_expr) + return None + + out_dt = me_get_dtype(out_expr) + itemsize = me_get_itemsize(out_expr) + me_free(out_expr) + + if out_dt == ME_STRING: + if itemsize == 0 or itemsize % 4 != 0: + return None + return np.dtype(" blosc2.LazyExpr: return blosc2.LazyExpr(new_op=(self, "+", value)) def __radd__(self, value: int | float | blosc2.Array, /) -> blosc2.LazyExpr: - return self.__add__(value) + # Order matters: `+` on strings is concatenation, not commutative. + _check_allowed_dtypes(value) + return blosc2.LazyExpr(new_op=(value, "+", self)) def __iadd__(self, value: int | float | blosc2.Array, /) -> blosc2.LazyExpr: return self.__add__(value) diff --git a/tests/ndarray/test_string_output.py b/tests/ndarray/test_string_output.py new file mode 100644 index 000000000..bed3d7488 --- /dev/null +++ b/tests/ndarray/test_string_output.py @@ -0,0 +1,101 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +"""String-valued expression results (miniexpr string output). + +The values are checked against NumPy, but the *width* matters just as much: the +output container is allocated before evaluation, so if it is sized from NumPy's +``result_type`` instead of miniexpr's inference the kernel silently truncates. +""" + +import numpy as np +import pytest + +import blosc2 +from blosc2 import blosc2_ext + +NAMES = [ + "Cozy Loft With City View", + "Small Single Room", + "Studio", + "Double Room", +] + + +@pytest.fixture +def names(): + return np.array(NAMES * 64, dtype="= expected.dtype.itemsize + + +@pytest.mark.parametrize("func", ["lower", "upper"]) +def test_case_matches_numpy(names, func): + arr = blosc2.asarray(names) + got = getattr(blosc2, func)(arr).compute() + expected = getattr(np.strings, func)(names) + assert list(got[:]) == list(expected) + + +def test_case_expansion_matches_numpy(): + # NumPy uses full case mapping; a 1:1 table would give "STRAßE" here. + src = np.array(["straße", "fix"] * 64, dtype=" Date: Sun, 26 Jul 2026 22:19:06 +0200 Subject: [PATCH 02/86] Phase 1h (blosc2 side): lower Python string syntax to the DSL grammar The DSL grammar has no attribute syntax, no `in` operator and no tuple unpacking, so a pandas UDF written in ordinary Python does not parse. That defeats the point of `df.apply(f, axis=1, engine=blosc2.jit)`, which is to swap in the engine without rewriting the function. _StringSyntaxRewriter handles it as an AST pass, alongside the existing _NumpyAttrCallRewriter, rather than as C parser work: s.lower() -> lower(s) x in s / x not in s -> contains(s, x) / not contains(s, x) a, b = s.split(sep, 1) -> a = split_part(s, sep, 0) b = split_part(s, sep, 1) The unpack deliberately emits two statements on separate lines rather than a ';'-joined one: miniexpr now rejects that form outright, and silently dropped the second statement before. Only the maxsplit=1 shape is handled, which is what tuple unpacking can consume. LazyUDF also asks miniexpr for the output dtype of a string-valued kernel, for the same reason LazyExpr.dtype does: the container is allocated up front and nothing on the Python side can predict a concat or case-mapping width. Co-Authored-By: Claude Opus 5 --- src/blosc2/dsl_kernel.py | 135 +++++++++++++++++++++++++++++++++++++++ src/blosc2/lazyexpr.py | 35 ++++++++++ 2 files changed, 170 insertions(+) diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index 82c11b373..f6e3c93d3 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -39,6 +39,122 @@ class DSLSyntaxError(ValueError): } +# Python string methods the DSL grammar exposes as plain functions. The DSL +# parser has no attribute syntax, so `desc.lower()` has to become `lower(desc)` +# before the source reaches miniexpr. +_STRING_METHOD_TO_DSL_FUNC = { + "lower": "lower", + "upper": "upper", + "strip": "strip", + "lstrip": "lstrip", + "rstrip": "rstrip", + "removeprefix": "removeprefix", + "removesuffix": "removesuffix", + "replace": "replace", + "startswith": "startswith", + "endswith": "endswith", +} + + +class _StringSyntaxRewriter(ast.NodeTransformer): + """Make ordinary Python string syntax parseable by the DSL grammar. + + Three rewrites, all shape-preserving: + + ``s.lower()`` -> ``lower(s)`` + ``x in s`` -> ``contains(s, x)`` (``not in`` negated) + ``a, b = s.split(sep, 1)`` + -> ``a = split_part(s, sep, 0)`` + ``b = split_part(s, sep, 1)`` + + The point is that a pandas UDF written in normal Python runs unmodified; + without this, `df.apply(f, axis=1, engine=blosc2.jit)` would require the + user to rewrite their function into function-call form first. + """ + + def __init__(self): + self.rewrote_any = False + + def visit_Call(self, node: ast.Call) -> ast.AST: + self.generic_visit(node) + func = node.func + if isinstance(func, ast.Attribute) and func.attr in _STRING_METHOD_TO_DSL_FUNC: + # `np.foo(...)` is handled by _NumpyAttrCallRewriter; leave it alone. + dsl_name = _STRING_METHOD_TO_DSL_FUNC[func.attr] + new_call = ast.Call( + func=ast.Name(id=dsl_name, ctx=ast.Load()), + args=[func.value, *node.args], + keywords=node.keywords, + ) + self.rewrote_any = True + return ast.copy_location(new_call, node) + return node + + def visit_Compare(self, node: ast.Compare) -> ast.AST: + self.generic_visit(node) + if len(node.ops) != 1 or not isinstance(node.ops[0], (ast.In, ast.NotIn)): + return node + needle, haystack = node.left, node.comparators[0] + call = ast.Call( + func=ast.Name(id="contains", ctx=ast.Load()), + args=[haystack, needle], + keywords=[], + ) + self.rewrote_any = True + if isinstance(node.ops[0], ast.NotIn): + call = ast.UnaryOp(op=ast.Not(), operand=call) + return ast.copy_location(call, node) + + def visit_Assign(self, node: ast.Assign): + self.generic_visit(node) + if len(node.targets) != 1 or not isinstance(node.targets[0], ast.Tuple): + return node + targets = node.targets[0].elts + parts = self._split_call_parts(node.value, len(targets)) + if parts is None: + # Leave it for the validator to reject with a proper message. + return node + self.rewrote_any = True + out = [] + for target, value in zip(targets, parts, strict=True): + assign = ast.Assign(targets=[target], value=value) + out.append(ast.copy_location(assign, node)) + return out + + @staticmethod + def _split_call_parts(value, count): + """Turn ``s.split(sep, 1)`` into ``count`` split_part() calls, or None. + + Only the maxsplit=1 form is supported, which is what tuple unpacking can + consume; a general N-way split has no fixed arity to unpack into. + """ + if count != 2 or not isinstance(value, ast.Call): + return None + func = value.func + name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", None) + if name != "split": + return None + if isinstance(func, ast.Attribute): + subject, args = func.value, list(value.args) + else: + if not value.args: + return None + subject, args = value.args[0], list(value.args[1:]) + if len(args) != 2: + return None + sep, maxsplit = args + if not (isinstance(maxsplit, ast.Constant) and maxsplit.value == 1): + return None + return [ + ast.Call( + func=ast.Name(id="split_part", ctx=ast.Load()), + args=[subject, sep, ast.Constant(value=k)], + keywords=[], + ) + for k in range(count) + ] + + class _NumpyAttrCallRewriter(ast.NodeTransformer): """Rewrite `alias.foo(...)` calls to the bare `foo(...)` form the DSL grammar requires, for every *alias* bound to the real NumPy module. Also applies @@ -603,6 +719,8 @@ def _extract_dsl(self, func, validate: bool = True): func, dsl_source, dsl_tree, dsl_func, input_names ) + dsl_source, dsl_tree, dsl_func = self._rewrite_string_syntax(dsl_source, dsl_tree, dsl_func) + if validate: DSLValidator(dsl_source, input_names=input_names).validate(dsl_func) if _PRINT_DSL_KERNEL: @@ -611,6 +729,23 @@ def _extract_dsl(self, func, validate: bool = True): print(dsl_source) return dsl_source, input_names + @staticmethod + def _rewrite_string_syntax(dsl_source, dsl_tree, dsl_func): + """Lower Python string syntax to the DSL's function-call grammar. + + No-op (returning the inputs unchanged) when there is nothing to rewrite. + """ + rewriter = _StringSyntaxRewriter() + rewritten = rewriter.visit(ast.parse(dsl_source)) + if not rewriter.rewrote_any: + return dsl_source, dsl_tree, dsl_func + + ast.fix_missing_locations(rewritten) + new_source = ast.unparse(rewritten) + new_tree = ast.parse(new_source) + new_func = next((node for node in new_tree.body if isinstance(node, ast.FunctionDef)), None) + return new_source, new_tree, new_func + @staticmethod def _rewrite_numpy_attr_calls(func, dsl_source, dsl_tree, dsl_func, input_names): """Rewrite `np.foo(...)` calls to bare `foo(...)`, for every name in *func*'s diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index b961eab06..8a82e02f9 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -4659,6 +4659,35 @@ def _align_dsl_operand_grids(inputs): return aligned +def _dsl_kernel_string_dtype(func, inputs): + """Output dtype of a string-returning DSL kernel, per miniexpr. + + Returns None when the kernel is not string-valued or miniexpr cannot type + it, leaving the existing dtype handling in charge. + """ + try: + params = list(getattr(func, "input_names", None) or []) + if not params or len(params) != len(inputs): + return None + dtypes = {} + for name, arr in zip(params, inputs, strict=True): + dt = getattr(arr, "dtype", None) + if dt is None: + return None + dtypes[name] = dt + if not any(np.dtype(dt).kind == "U" for dt in dtypes.values()): + return None + + from blosc2 import blosc2_ext + + out = blosc2_ext.me_output_dtype(func.dsl_source, dtypes) + except Exception: + return None + if out is None or np.dtype(out).kind != "U": + return None + return np.dtype(out) + + class LazyUDF(LazyArray): def __init__( self, func, inputs, dtype, shape=None, chunked_eval=True, jit=None, jit_backend=None, **kwargs @@ -4677,6 +4706,12 @@ def __init__( else: self._shape = shape + if dtype is None and isinstance(func, DSLKernel) and func.dsl_source is not None: + # A string-returning DSL kernel needs miniexpr's own width inference: + # the output container is allocated before evaluation, and nothing on + # the Python side can predict a concat/case-mapping width. + dtype = _dsl_kernel_string_dtype(func, self.inputs) or dtype + self.kwargs = kwargs self.kwargs["dtype"] = dtype self.kwargs["shape"] = self._shape From 0f92c73b745e708908a59177bf820217eefdb418 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Sun, 26 Jul 2026 23:50:52 +0200 Subject: [PATCH 03/86] Size string DSL kernel output from miniexpr, and check it lazyudf() resolved a DSL kernel's dtype by np.result_type over the input dtypes before LazyUDF got a chance to ask miniexpr, so a string-returning kernel was allocated at the operand width and miniexpr wrote past it. - lazyudf(): ask miniexpr first for string kernels, promote as before otherwise. - _set_pref_expr(): the container is allocated before the compile, so verify the width miniexpr infers matches it instead of overrunning the block. - tests: pass strict_miniexpr=True throughout (these all passed on the numpy fallback before), and add the pandas-3 blog kernel as a DSL kernel. Co-Authored-By: Claude Opus 5 --- src/blosc2/blosc2_ext.pyx | 9 +++++ src/blosc2/lazyexpr.py | 4 ++- tests/ndarray/test_string_output.py | 54 +++++++++++++++++++++++++---- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 18e07e1e5..59f9e670f 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -4326,6 +4326,15 @@ cdef class NDArray: raise TypeError(f"miniexpr does not support operand or output dtype: {expression_display}; details: {me_error_msg}") if rc != ME_COMPILE_SUCCESS: raise NotImplementedError(f"Cannot compile expression: {expression_display}; details: {me_error_msg}") + # The output container was allocated before compiling, so a width miniexpr + # infers differently from the container's would overrun the block buffer. + cdef size_t inferred_itemsize = me_get_itemsize(out_expr) + if me_output_dtype == ME_STRING and inferred_itemsize != out_np_dtype.itemsize: + me_free(out_expr) + raise ValueError( + f"miniexpr infers a {inferred_itemsize}-byte string result for " + f"{expression_display}, but the output array is {out_np_dtype}" + ) udata.miniexpr_handle = out_expr # Free resources diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 8a82e02f9..69a6e846f 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -5187,7 +5187,9 @@ def inplace_udf(inputs_tuple, output, offset): raise TypeError( "Cannot infer dtype for DSL kernel with no array inputs; pass dtype= explicitly." ) - dtype = np.result_type(*dep_dtypes) + # A string-returning kernel is not a promotion of its inputs: only + # miniexpr knows the concat/case-mapping width bound. + dtype = _dsl_kernel_string_dtype(func, inputs) or np.result_type(*dep_dtypes) else: raise TypeError("dtype is required for non-DSL UDFs.") return LazyUDF(func, inputs, dtype, shape, chunked_eval, jit, jit_backend, **kwargs) diff --git a/tests/ndarray/test_string_output.py b/tests/ndarray/test_string_output.py index bed3d7488..cc8c6ecd8 100644 --- a/tests/ndarray/test_string_output.py +++ b/tests/ndarray/test_string_output.py @@ -36,7 +36,7 @@ def test_concat_scalar_does_not_truncate(): # numpy's view of the *operand* would drop the suffix entirely. full = np.array(["A" * 16] * 128, dtype=" Date: Sun, 26 Jul 2026 23:52:52 +0200 Subject: [PATCH 04/86] docs: release note for string expressions and DSL kernels Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index dc98b3e53..74e0e82bb 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -6,6 +6,17 @@ XXX version-specific blurb XXX ### New features +- **String-valued expressions and DSL kernels** over fixed-width ` ME_BYTES. - both `v.dtype.num == 19` itemsize gates now accept 18 (NPY_STRING); only one of the two was reachable for `U`, and both are for `S`. - `me_output_dtype` reports an `Sn` dtype back, and the string-dtype gates in lazyexpr test `kind in "US"`. - the DSL validator accepts `bytes` constants, so `b"x" in col` compiles. Verified against np.strings: concat width, ASCII-only case mapping (upper on `S8` stays `S8`), predicates, and a bytes DSL kernel. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 8 +++- src/blosc2/blosc2_ext.pyx | 17 ++++++-- src/blosc2/dsl_kernel.py | 2 +- src/blosc2/lazyexpr.py | 8 ++-- tests/ndarray/test_string_output.py | 60 +++++++++++++++++++++++++++-- 5 files changed, 81 insertions(+), 14 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 74e0e82bb..5968cd3f9 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -15,8 +15,12 @@ XXX version-specific blurb XXX unpacking (`before, after = desc.split(sep, 1)`), which are rewritten to the DSL grammar. The output width is inferred by miniexpr and the container is allocated from it, so nothing truncates — `.dtype` may be - wider than NumPy's exact answer, never narrower. Variable-width `utf8()` - columns and bytes (`S`) dtypes still use the NumPy path. + wider than NumPy's exact answer, never narrower. +- **Bytes (`S`) arrays** go through the same engine, with NumPy's `S` + semantics rather than `-1 @@ -987,7 +992,7 @@ def me_output_dtype(expression, operands): var.address = NULL var.type = 0 var.context = NULL - var.itemsize = operand_dtype.itemsize if operand_dtype.num == 19 else 0 + var.itemsize = operand_dtype.itemsize if operand_dtype.num in (18, 19) else 0 built += 1 expression_bytes = ( @@ -1007,6 +1012,10 @@ def me_output_dtype(expression, operands): if itemsize == 0 or itemsize % 4 != 0: return None return np.dtype(" out_np_dtype.itemsize: + if me_output_dtype in (ME_STRING, ME_BYTES) and inferred_itemsize != out_np_dtype.itemsize: me_free(out_expr) raise ValueError( f"miniexpr infers a {inferred_itemsize}-byte string result for " @@ -4398,7 +4407,7 @@ cdef class NDArray: var.address = NULL var.type = 0 var.context = NULL - var.itemsize = v.dtype.itemsize if v.dtype.num == 19 else 0 + var.itemsize = v.dtype.itemsize if v.dtype.num in (18, 19) else 0 cdef bytes expression_bytes = ( (expression).encode("utf-8") if isinstance(expression, str) else expression diff --git a/src/blosc2/dsl_kernel.py b/src/blosc2/dsl_kernel.py index f6e3c93d3..ad654f64a 100644 --- a/src/blosc2/dsl_kernel.py +++ b/src/blosc2/dsl_kernel.py @@ -575,7 +575,7 @@ def _expr(self, node: ast.AST): # noqa: C901 return if isinstance(node, ast.Constant): val = node.value - if isinstance(val, bool | int | float | str): + if isinstance(val, bool | int | float | str | bytes): return self._err(node, "Unsupported constant in DSL expression") if isinstance(node, ast.UnaryOp): diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 69a6e846f..3fca887d2 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -3620,14 +3620,14 @@ def _miniexpr_string_dtype(self): if dt is None: return None dtypes[k] = dt - if not any(np.dtype(dt).kind == "U" for dt in dtypes.values()): + if not any(np.dtype(dt).kind in "US" for dt in dtypes.values()): return None from blosc2 import blosc2_ext out = blosc2_ext.me_output_dtype(self.expression, dtypes) except Exception: return None - if out is not None and np.dtype(out).kind != "U": + if out is not None and np.dtype(out).kind not in "US": out = None self._me_str_dtype_ = (out,) self._me_str_expr_ = self.expression @@ -4675,7 +4675,7 @@ def _dsl_kernel_string_dtype(func, inputs): if dt is None: return None dtypes[name] = dt - if not any(np.dtype(dt).kind == "U" for dt in dtypes.values()): + if not any(np.dtype(dt).kind in "US" for dt in dtypes.values()): return None from blosc2 import blosc2_ext @@ -4683,7 +4683,7 @@ def _dsl_kernel_string_dtype(func, inputs): out = blosc2_ext.me_output_dtype(func.dsl_source, dtypes) except Exception: return None - if out is None or np.dtype(out).kind != "U": + if out is None or np.dtype(out).kind not in "US": return None return np.dtype(out) diff --git a/tests/ndarray/test_string_output.py b/tests/ndarray/test_string_output.py index cc8c6ecd8..ed8a21e09 100644 --- a/tests/ndarray/test_string_output.py +++ b/tests/ndarray/test_string_output.py @@ -127,10 +127,10 @@ def format_room_info(property_type, name): def test_probe_reports_none_for_unsupported(): - # Bytes are not wired up yet; the probe must say so rather than guess, so - # the caller keeps its numpy path. - assert blosc2_ext.me_output_dtype("o0 + o1", {"o0": "S8", "o1": "S8"}) is None + # When miniexpr cannot type an expression the probe must say so rather than + # guess, so the caller keeps its numpy path. assert blosc2_ext.me_output_dtype("nosuchfunc(o0)", {"o0": "= 0) + + +def test_bytes_dsl_kernel(raws): + @blosc2.dsl_kernel + def tag(x): + if b"o" in x: + return x + return b"long-" + x + + arr = blosc2.asarray(raws) + got = blosc2.lazyudf(tag, (arr,)).compute(strict_miniexpr=True) + expected = [v if b"o" in v else b"long-" + v for v in raws] + assert list(got[:]) == expected + + +def test_bytes_and_str_do_not_mix(raws): + # NumPy raises on `S` + `U` too; miniexpr must not silently pick one. + assert blosc2_ext.me_output_dtype("o0 + o1", {"o0": "S8", "o1": " Date: Mon, 27 Jul 2026 00:20:06 +0200 Subject: [PATCH 06/86] Compile row["colname"] kernels that branch, and let strings through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `df.apply(f, axis=1, engine=blosc2.jit)` could not run any kernel combining `row["colname"]` with control flow — not even a numeric one. Tracing evaluated the `if` over a whole column ("truth value ... is ambiguous") and the DSL parser rejected the subscript first. _RowSubscriptRewriter (alongside the other AST rewrites) turns `def f(row): ... row["a"] ...` into `def f(a): ... a ...` when every mention of the row parameter is `param[]`; anything else still bails and is rejected as before. DSLKernel keeps the original labels, since they need not be identifiers, and _jit_dsl_wrapper pulls those columns out of the single row-proxy argument. String columns reach this route too: the whole-frame numeric gate is skipped for it (it reads one column at a time), `_miniexpr_eligible_operand` accepts "U"/"S", and `np.asarray` is bypassed -- on a pandas 3 `str` column it yields an object array of PyObject pointers. With that, the pandas-3 "format room info" kernel runs unmodified and byte-identically, reporting engine=miniexpr. Nulls in a string column are rejected rather than substituted: a row-wise kernel over a null raises in pandas too, so quietly making it "" would invent a value pandas never produces. The error names the column and the fix. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 10 +++ doc/guides/pandas_engine.md | 33 +++++++- src/blosc2/dsl_kernel.py | 92 +++++++++++++++++++++ src/blosc2/lazyexpr.py | 2 +- src/blosc2/proxy.py | 139 ++++++++++++++++++++++++-------- tests/test_pandas_udf_engine.py | 100 +++++++++++++++++++++-- 6 files changed, 334 insertions(+), 42 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 5968cd3f9..91a9e14cc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -21,6 +21,16 @@ XXX version-specific blurb XXX keep the width instead of growing) and ASCII-only stripping. `S` and `]``; anything else (positional + indexing, iteration, attribute access) is left for the validator to reject. + + ``columns`` maps the generated parameter names back to the original column + labels, which need not be identifiers. + """ + + def __init__(self, param: str): + self.param = param + self.columns: dict[str, str] = {} + self.bailed = False + self._names: dict[str, str] = {} + + def _param_for(self, label: str) -> str: + if label in self._names: + return self._names[label] + candidate = label if label.isidentifier() and not keyword.iskeyword(label) else "" + if not candidate or candidate in self.columns or candidate == self.param: + candidate = f"_col{len(self._names)}" + self._names[label] = candidate + self.columns[candidate] = label + return candidate + + def visit_Subscript(self, node: ast.Subscript) -> ast.AST: + # Match before descending: visiting `node.value` would see the bare row + # name and trip the bail-out below. + if isinstance(node.value, ast.Name) and node.value.id == self.param: + key = node.slice + if not (isinstance(key, ast.Constant) and isinstance(key.value, str)): + self.bailed = True + return node + return ast.copy_location(ast.Name(id=self._param_for(key.value), ctx=ast.Load()), node) + self.generic_visit(node) + return node + + def visit_Name(self, node: ast.Name) -> ast.AST: + # Any surviving bare mention of the row parameter is a use we cannot + # turn into a column reference. + if node.id == self.param: + self.bailed = True + return node + + class _NumpyAttrCallRewriter(ast.NodeTransformer): """Rewrite `alias.foo(...)` calls to the bare `foo(...)` form the DSL grammar requires, for every *alias* bound to the real NumPy module. Also applies @@ -674,6 +729,9 @@ def __init__(self, func): self.dsl_source = None self.input_names = None self.dsl_error = None + # Set by _rewrite_row_subscripts when the kernel takes a row proxy. + self.row_param = None + self.row_columns = None try: dsl_source, input_names = self._extract_dsl(func) except DSLSyntaxError as e: @@ -715,6 +773,14 @@ def _extract_dsl(self, func, validate: bool = True): raise ValueError("No function definition found in sliced DSL source") input_names = self._input_names_from_signature(dsl_func) + dsl_source, dsl_tree, dsl_func, row_columns = self._rewrite_row_subscripts( + dsl_source, dsl_tree, dsl_func, input_names + ) + if row_columns is not None: + self.row_param = input_names[0] + self.row_columns = row_columns + input_names = self._input_names_from_signature(dsl_func) + dsl_source, dsl_tree, dsl_func = self._rewrite_numpy_attr_calls( func, dsl_source, dsl_tree, dsl_func, input_names ) @@ -729,6 +795,32 @@ def _extract_dsl(self, func, validate: bool = True): print(dsl_source) return dsl_source, input_names + @staticmethod + def _rewrite_row_subscripts(dsl_source, dsl_tree, dsl_func, input_names): + """Rewrite ``row["col"]`` into named parameters; see _RowSubscriptRewriter. + + Returns ``(source, tree, func, columns)`` with *columns* mapping the new + parameter names to the original column labels, or None as the fourth + element when the rewrite does not apply and nothing was changed. + """ + if len(input_names) != 1: + return dsl_source, dsl_tree, dsl_func, None + rewriter = _RowSubscriptRewriter(input_names[0]) + rewritten = rewriter.visit(ast.parse(dsl_source)) + if rewriter.bailed or not rewriter.columns: + return dsl_source, dsl_tree, dsl_func, None + + new_func = next((n for n in rewritten.body if isinstance(n, ast.FunctionDef)), None) + if new_func is None: + return dsl_source, dsl_tree, dsl_func, None + new_func.args.args = [ast.arg(arg=name) for name in rewriter.columns] + new_func.args.posonlyargs = [] + ast.fix_missing_locations(rewritten) + new_source = ast.unparse(rewritten) + new_tree = ast.parse(new_source) + new_func = next((n for n in new_tree.body if isinstance(n, ast.FunctionDef)), None) + return new_source, new_tree, new_func, dict(rewriter.columns) + @staticmethod def _rewrite_string_syntax(dsl_source, dsl_tree, dsl_func): """Lower Python string syntax to the DSL's function-call grammar. diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 3fca887d2..f3eadae20 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -1761,7 +1761,7 @@ def _miniexpr_eligible_operand(op): and op.ndim > 0 and op.shape == shape and op.dtype.isnative - and op.dtype.kind in "biufc" + and op.dtype.kind in "biufcUS" ) return False diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index a827f00b1..73abb53b4 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -762,6 +762,44 @@ def as_simpleproxy(*arrs: Sequence[blosc2.Array]) -> tuple[SimpleProxy | blosc2. return out[0] if len(out) == 1 else out +def _is_pandas_string_series(col) -> bool: + """True for a pandas string column. + + pandas 3's `str` dtype reports `kind == "O"`, so the kind is useless here + and `pd.api.types.is_string_dtype` is the only reliable test. + """ + try: + import pandas as pd + except ImportError: + return False + return pd.api.types.is_string_dtype(getattr(col, "dtype", None)) + + +def _string_series_to_numpy(col, label=None): + """A pandas string column as a fixed-width ` TypeError, + `row['x'].lower()` -> AttributeError), so quietly turning one into `""` + would invent a value pandas never produces. + """ + if label is None: + label = col.name + if col.isna().any(): + raise ValueError( + f"blosc2.jit: string column {label!r} contains nulls, and a row-wise kernel " + "over a null raises in pandas too. Fill them first, e.g. " + f"df[{label!r}] = df[{label!r}].fillna('')." + ) + values = col.to_numpy(dtype=object) + return values.astype(str) + + class _PandasRowProxy(blosc2.Operand): """Row proxy for `PandasUdfEngine.apply`'s axis=1 route. @@ -777,6 +815,17 @@ def __init__(self, df): self._df = df self._cache = {} + def _raw_column(self, key): + """The column as a plain array, for the DSL route. + + The tracing route wants a SimpleProxy operand; a DSL kernel wants the + array itself, and accepts string columns the traced one does not. + """ + col = self._df[key] + if _is_pandas_string_series(col): + return _string_series_to_numpy(col, key) + return col.to_numpy() + def __getitem__(self, key): if not isinstance(key, str): raise TypeError( @@ -796,11 +845,11 @@ def __getitem__(self, key): f"row[{key!r}]: column label is duplicated ({n_matches} matches); " "axis=1 row proxies require unique column labels" ) - col = self._df[key].to_numpy() - if col.dtype.kind not in "biufc": + col = self._raw_column(key) + if col.dtype.kind not in "biufcUS": raise ValueError( - f"row[{key!r}]: column has dtype {col.dtype!r}, which is not numeric. " - "The Blosc2 engine only supports vectorized numeric computations." + f"row[{key!r}]: column has dtype {col.dtype!r}, which the Blosc2 engine " + "cannot vectorize. Numeric, boolean and string columns are supported." ) proxy = SimpleProxy(col) self._cache[key] = proxy @@ -904,6 +953,47 @@ def _signature_params(func) -> list: return [] +def _row_column(row, label): + """The raw column array behind *label*, from a row proxy or a DataFrame.""" + getter = getattr(row, "_raw_column", None) + if getter is not None: + return getter(label) + col = row[label] + if isinstance(col, np.ndarray | blosc2.NDArray): + return col + if _is_pandas_string_series(col): + return _string_series_to_numpy(col, label) + return np.asarray(col) + + +def _dsl_operand_values(kernel: DSLKernel, sig, args, func_kwargs) -> tuple: + """The kernel's operands, one per DSL input name, in declaration order.""" + if kernel.row_columns and len(args) == 1 and not func_kwargs: + # The `row["colname"]` kernel: its signature still says one row, but the + # compiled kernel takes one parameter per referenced column. + values = tuple(_row_column(args[0], label) for label in kernel.row_columns.values()) + else: + try: + bound = sig.bind(*args, **func_kwargs) + except TypeError as e: + # sig.bind's message names no function; prefix it, and point at the + # subsetting fix when a wide DataFrame was unpacked into the call. + hint = _wide_frame_hint(e, kernel.__name__, kernel.input_names or sig.parameters) + raise TypeError(f"{kernel.__name__}() {e}" + (f"\n{hint}" if hint else "")) from None + bound.apply_defaults() + values = tuple(bound.arguments[name] for name in kernel.input_names) + # Accept array-protocol operands (pandas Series, polars Series, ...) the same + # way the tracing route already does; zero-copy when the source is numpy-backed. + return tuple( + np.asarray(v) + if not isinstance(v, np.ndarray | blosc2.NDArray) + and hasattr(v, "__array__") + and getattr(v, "ndim", 0) > 0 + else v + for v in values + ) + + def _jit_dsl_wrapper(kernel: DSLKernel, out, decorator_kwargs: dict): """Build the call wrapper for the DSL (control-flow) dispatch route of `jit`. @@ -917,26 +1007,7 @@ def dsl_wrapper(*args, **func_kwargs): sig = kernel._sig if sig is None: raise TypeError(f"@blosc2.jit: cannot introspect the signature of {kernel.__name__!r}") - try: - bound = sig.bind(*args, **func_kwargs) - except TypeError as e: - # sig.bind's message names no function; prefix it, and point at the - # subsetting fix when a wide DataFrame was unpacked into the call. - hint = _wide_frame_hint(e, kernel.__name__, kernel.input_names or sig.parameters) - raise TypeError(f"{kernel.__name__}() {e}" + (f"\n{hint}" if hint else "")) from None - bound.apply_defaults() - values = tuple(bound.arguments[name] for name in kernel.input_names) - # Accept array-protocol operands (pandas Series, polars Series, ...) the - # same way the tracing route already does; zero-copy when the source is - # numpy-backed. - values = tuple( - np.asarray(v) - if not isinstance(v, np.ndarray | blosc2.NDArray) - and hasattr(v, "__array__") - and getattr(v, "ndim", 0) > 0 - else v - for v in values - ) + values = _dsl_operand_values(kernel, sig, args, func_kwargs) array_shapes = { v.shape @@ -1266,13 +1337,21 @@ def apply(cls, data, func, args, kwargs, decorator, axis): function once for each column or row. """ orig = data - values = cls._ensure_numpy_data(data) func_name = getattr(func, "__name__", "the function") uses_subscript, has_loop = ( _analyze_row_func(_undecorated(func)) if hasattr(orig, "columns") else (False, False) ) + # The row-proxy route reads columns one at a time and never needs the + # whole frame as one array, so a non-numeric column (a pandas string + # column, say) is fine there and only `nrows` is wanted. + if uses_subscript and axis in (1, "columns"): + values = None + nrows = len(orig) + else: + values = cls._ensure_numpy_data(data) + nrows = values.shape[0] func = _decorate_once(func, decorator) - if values.ndim == 1 or axis is None: + if values is not None and (values.ndim == 1 or axis is None): # pandas Series.apply or pipe result = func(values, *args, **kwargs) elif axis in (0, "index"): @@ -1302,14 +1381,10 @@ def apply(cls, data, func, args, kwargs, decorator, axis): # per-column dtypes survive. row_proxy = _PandasRowProxy(orig) result = func(row_proxy, *args, **kwargs) - if not ( - isinstance(result, np.ndarray) - and result.ndim == 1 - and result.shape[0] == values.shape[0] - ): + if not (isinstance(result, np.ndarray) and result.ndim == 1 and result.shape[0] == nrows): raise TypeError( '@blosc2.jit engine=... axis=1: functions using row["colname"] must ' - f"return one scalar per row (shape ({values.shape[0]},)); got " + f"return one scalar per row (shape ({nrows},)); got " f"{result!r}. Returning multiple values per row is not supported here." ) else: diff --git a/tests/test_pandas_udf_engine.py b/tests/test_pandas_udf_engine.py index f9df6bc16..7fa10d4df 100644 --- a/tests/test_pandas_udf_engine.py +++ b/tests/test_pandas_udf_engine.py @@ -238,16 +238,15 @@ def bad(row): with pytest.raises(AttributeError, match="row\\['b'\\]"): df.apply(bad, engine=blosc2.jit, axis=1) - def test_apply_axis1_row_subscript_non_numeric_column_raises(self): - # Whole-frame numeric-dtype validation (`_ensure_numpy_data`) already - # gates this ahead of row-proxy dispatch; `_PandasRowProxy` carries - # its own per-column check too, for callers that construct it - # directly. + def test_apply_axis1_row_subscript_unvectorizable_column_raises(self): + # String columns are supported now, so the per-column check in + # `_PandasRowProxy` is what still rejects a dtype the engine cannot + # vectorize at all. def bad(row): - return row["a"] + len(row["b"]) + return row["a"] + row["b"] - df = pd.DataFrame({"a": [1.0, 2.0], "b": ["x", "y"]}) - with pytest.raises(ValueError, match="numeric dtype"): + df = pd.DataFrame({"a": [1.0, 2.0], "b": pd.to_datetime(["2020-01-01", "2020-01-02"])}) + with pytest.raises(ValueError, match="cannot vectorize"): df.apply(bad, engine=blosc2.jit, axis=1) def test_apply_axis1_positional_idiom_still_uses_per_row_loop(self): @@ -377,3 +376,88 @@ def dsl(a, b): # A missing operand is a different mistake and keeps its own message with pytest.raises(TypeError, match="missing a required argument"): dsl(a=df["a"]) + + +@pytest.mark.skipif(pd is None, reason="pandas not installed") +@pytest.mark.skipif(_pandas_too_old, reason="engine= integration targets pandas 3.x") +class TestRowKernelsWithControlFlow: + """`row["colname"]` combined with an `if`. + + Neither dispatch route could run these before: tracing evaluates the `if` + over a whole column ("truth value ... is ambiguous") and the DSL parser + rejected the subscript. They are now rewritten into named parameters. + """ + + def test_numeric_row_kernel_with_branch(self): + def pick(row): + if row["a"] > 2: + return row["a"] + row["b"] + return row["a"] - row["b"] + + df = pd.DataFrame({"a": [1.0, 2.0, 3.0, 4.0], "b": [10.0, 20.0, 30.0, 40.0]}) + expected = df.apply(pick, axis=1) + result = df.apply(pick, axis=1, engine=blosc2.jit) + pd.testing.assert_series_equal(result, expected) + + def test_blog_kernel_matches_default_engine(self): + """End-to-end acceptance: the pandas-3 blog kernel, run unmodified.""" + + def format_room_info(row): + result = "property_type=" + row["property_type"] + desc = row["name"].lower() + if " with " not in desc: + return result + ", room_type=" + desc.removesuffix(" room") + before, after = desc.split(" with ", 1) + r2 = result + ", room_type=" + before.removesuffix(" room") + return r2 + ", amenity=" + after + + df = pd.DataFrame( + { + "property_type": ["Entire home", "Private room", "Shared room", "Loft"] * 8, + "name": [ + "Cozy Loft With City View", + "Small Single Room", + "Studio with balcony", + "Double Room", + ] + * 8, + } + ) + expected = df.apply(format_room_info, axis=1) + result = df.apply(format_room_info, axis=1, engine=blosc2.jit) + pd.testing.assert_series_equal(result, expected) + + def test_non_identifier_column_label(self): + def tag(row): + if row["room type"] == "loft": + return "L" + return "-" + + df = pd.DataFrame({"room type": ["loft", "studio", "loft"]}) + expected = df.apply(tag, axis=1) + result = df.apply(tag, axis=1, engine=blosc2.jit) + pd.testing.assert_series_equal(result, expected) + + def test_null_string_column_is_rejected(self): + # pandas raises on a row kernel over a null too; substituting "" would + # invent a value it never produces. + def concat(row): + return "p=" + row["x"] + + df = pd.DataFrame({"x": ["a", None, "c"]}) + with pytest.raises(TypeError): + df.apply(concat, axis=1) + with pytest.raises(ValueError, match="contains nulls"): + df.apply(concat, axis=1, engine=blosc2.jit) + + def test_positional_row_access_still_rejected(self): + # Only `row["literal"]` is rewritten; anything else must keep failing + # loudly rather than silently taking a different route. + def positional(row): + if row[0] > 1: + return row[0] + return row[1] + + df = pd.DataFrame({"a": [1.0, 2.0], "b": [3.0, 4.0]}) + with pytest.raises((TypeError, ValueError, RuntimeError)): + df.apply(positional, axis=1, engine=blosc2.jit) From cbcb15b50837a2b5eabfbf0b8abbc1c139f0ab8d Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 06:48:43 +0200 Subject: [PATCH 07/86] Fix miniexpr prefilter reads for operands wider than 255 bytes c-blosc2 caps a typesize above BLOSC_MAX_TYPESIZE (255) to 1 in the chunk header so its split machinery keeps working ("treat buffer as an 1-byte stream", blosc2.c). aux_miniexpr() asked blosc2_getitem_ctx() for the operand block in *element* units, which the chunk then interpreted as bytes: block 0 came back with only its first few elements populated and every later block was the untouched malloc'd buffer. Results were wrong and non-deterministic -- `arr == "hello"` over 1200 rows of --- src/blosc2/blosc2_ext.pyx | 20 +++++++++++++++++--- tests/ndarray/test_string_output.py | 25 +++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 9bffcb37f..8c4b62573 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -287,7 +287,7 @@ cdef extern from "blosc2.h": int blosc1_cbuffer_validate(const void* cbuffer, size_t cbytes, size_t* nbytes) - void blosc1_cbuffer_metainfo(const void* cbuffer, size_t* typesize, int* flags) + void blosc1_cbuffer_metainfo(const void* cbuffer, size_t* typesize, int* flags) nogil void blosc1_cbuffer_versions(const void* cbuffer, int* version, int* versionlz) @@ -2660,6 +2660,10 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, cdef me_input_cache_s* input_cache cdef int32_t chunk_nbytes, chunk_cbytes, block_nbytes cdef int start, blocknitems, expected_blocknitems + cdef size_t header_typesize + cdef int header_flags + cdef int64_t getitem_start + cdef int32_t getitem_nitems cdef int64_t valid_nitems cdef int64_t global_block cdef int32_t input_typesize @@ -2832,7 +2836,17 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, expected_blocknitems = blocknitems elif blocknitems != expected_blocknitems: raise ValueError("miniexpr: inconsistent block element counts across inputs") - start = nblock * blocknitems + # blosc2_getitem_ctx() counts in the typesize the *chunk header* records, + # which is not always the array's: c-blosc2 caps a typesize above + # BLOSC_MAX_TYPESIZE (255) to 1 so its split machinery keeps working + # (blosc2.c, "treat buffer as an 1-byte stream"). Asking in element + # units then silently reads a byte range instead -- every block past the + # first came back as uninitialised memory. Convert through bytes. + blosc1_cbuffer_metainfo(src, &header_typesize, &header_flags) + if header_typesize <= 0: + raise ValueError("miniexpr: invalid chunk typesize") + getitem_start = ( nblock * block_nbytes) // header_typesize + getitem_nitems = block_nbytes // header_typesize # This is needed for thread safety, but adds a pretty low overhead (< 400ns on a modern CPU) # In the future, perhaps one can create a specific (serial) context just for # blosc2_getitem_ctx, but this is probably never going to be necessary. @@ -2841,7 +2855,7 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, # dctx = ndarr.sc.dctx if valid_nitems > blocknitems: raise ValueError("miniexpr: valid items exceed padded block size") - rc = blosc2_getitem_ctx(dctx, src, chunk_cbytes, start, blocknitems, + rc = blosc2_getitem_ctx(dctx, src, chunk_cbytes, getitem_start, getitem_nitems, input_buffers[i], block_nbytes) blosc2_free_ctx(dctx) if rc < 0: diff --git a/tests/ndarray/test_string_output.py b/tests/ndarray/test_string_output.py index ed8a21e09..09726d550 100644 --- a/tests/ndarray/test_string_output.py +++ b/tests/ndarray/test_string_output.py @@ -195,3 +195,28 @@ def tag(x): def test_bytes_and_str_do_not_mix(raws): # NumPy raises on `S` + `U` too; miniexpr must not silently pick one. assert blosc2_ext.me_output_dtype("o0 + o1", {"o0": "S8", "o1": " Date: Mon, 27 Jul 2026 06:53:40 +0200 Subject: [PATCH 08/86] Phase 3: string expressions over utf8 columns, plus utf8_array() Lifts the NotImplementedError on `t.where("name == 'x'")` and friends for variable-length utf8 columns. Only the operator form `t[t.name == "x"]` worked before. A utf8 column cannot be an expression operand: its offsets and data live in separate NDArrays with independent chunk grids, so the prefilter contract does not apply. So drive it instead -- _utf8_span_eval() walks the column in row spans, materializes each span to a fixed-width --- RELEASE_NOTES.md | 24 +++++ doc/reference/classes.rst | 1 + doc/reference/ctable.rst | 18 ++-- src/blosc2/__init__.py | 3 + src/blosc2/ctable.py | 148 +++++++++++++++++++++++++++++-- src/blosc2/utf8_array.py | 53 ++++++++++++ tests/ctable/test_utf8.py | 178 ++++++++++++++++++++++++++++++++++++-- 7 files changed, 403 insertions(+), 22 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 91a9e14cc..3398c2b02 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -21,6 +21,21 @@ XXX version-specific blurb XXX keep the width instead of growing) and ASCII-only stripping. `S` and ` 2)")` used to raise `NotImplementedError`; + only the operator form `t[t.name == "x"]` was available. A variable-length + column cannot be an expression operand (its offsets and data have + independent chunk grids, so the prefilter contract does not apply), so + these are evaluated span by span, each span materialized to a fixed-width + array whose width is rounded up to a power of two and handed to miniexpr. + Nulls are materialized to `""` before any kernel sees them and re-masked + afterwards, so a null never satisfies a predicate — the same answer the + operator form gives. +- **New `blosc2.utf8_array(seq, spec=None)`** builds a `Utf8Array` from an + iterable of strings; `Utf8Array` is exported too. Previously the only + construction path was `Utf8Array(spec)` + `.extend()` + `.flush()`, which + was not exported at all. - **`df.apply(f, axis=1, engine=blosc2.jit)` now runs `row["colname"]` kernels that contain an `if`.** Neither dispatch route could before: tracing evaluated the branch over a whole column (`truth value ... is @@ -83,6 +98,15 @@ message when opening a nonexistent `CTable` in append mode. ### Bug fixes +- **Expressions over operands wider than 255 bytes returned wrong results.** + c-blosc2 caps a typesize above 255 to 1 in the chunk header so its split + machinery keeps working, and the miniexpr prefilter was asking + `blosc2_getitem_ctx()` for operand blocks in element units, which the chunk + then read as a byte range: every block past the first was uninitialised + memory. `arr == "hello"` over 1200 rows of ```, ``>=``), but not the string-expression - form ``t.where("name == 'x'")`` yet. :meth:`CTable.create_index` on utf8 - columns is not supported yet either; both raise ``NotImplementedError`` - with a clear message. +.. [#utf8expr] utf8 columns support both the operator form + ``t[t.name == "x"]`` and the string-expression form + ``t.where("name == 'x'")``. Because a variable-length column cannot be an + expression operand directly, string expressions are evaluated span by span, + with each span materialized to a fixed-width array first; results are + identical to the operator form, including that a null never satisfies any + predicate. Nested (dotted) utf8 leaves and :meth:`CTable.create_index` on + utf8 columns are still unsupported and raise ``NotImplementedError`` with a + clear message. Note that a plain ``str`` annotation without an explicit :func:`field` spec still maps to fixed-width ``string(max_length=32)`` for backward diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index cea161a22..915f9195f 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -567,6 +567,7 @@ def _raise(exc): from .tree_store import TreeStore from .batch_array import Batch, BatchArray from .list_array import ListArray +from .utf8_array import Utf8Array, utf8_array from .objectarray import ObjectArray, objectarray_from_cframe from .ref import Ref from .b2objects import open_b2object @@ -832,6 +833,7 @@ def _raise(exc): "uint32", "uint64", "utf8", + "utf8_array", "vlbytes", "vlstring", # Grouped reductions @@ -879,6 +881,7 @@ def _raise(exc): "Tuner", "URLPath", "ObjectArray", + "Utf8Array", # Version "__version__", # Utils diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 3794f9ce9..8a7a0b7b5 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -540,6 +540,17 @@ def __repr__(self) -> str: # --------------------------------------------------------------------------- +def _utf8_span_dtype(span: np.ndarray) -> np.dtype: + """Fixed-width ``U`` dtype wide enough for every value in *span*. + + The width is span-local and data-dependent, whereas miniexpr bakes output + widths in at compile time, so round it up to a power of two: a column then + costs a handful of distinct compilations instead of one per span. + """ + longest = max((len(s) for s in span), default=0) + return np.dtype(f" int: """Translate a logical (valid-row) index into a physical array index. @@ -2580,10 +2591,13 @@ def _normalize_sum_where(self, where): if where is None: return None if isinstance(where, str): - self._table._guard_varlen_scalar_expression(where) + self._table._guard_varlen_scalar_expression(where, allow_utf8=True) + utf8_names = self._table._utf8_names_in(where) operands = self._table._where_expression_operands(where) where, operands = self._table._rewrite_nested_expression(where, operands) - where = blosc2.lazyexpr(where, operands) + where = self._table._lazyexpr_over_cols(where, operands, utf8_names) + if isinstance(where, np.ndarray): + where = blosc2.asarray(where) if isinstance(where, np.ndarray) and where.dtype == np.bool_: where = blosc2.asarray(where) if isinstance(where, Column): @@ -12656,7 +12670,7 @@ def _rewrite_nested_expression( def _expression_references_name(expr: str, name: str) -> bool: return re.search(rf"(? None: + def _guard_scalar_expression(self, expr: str, *, allow_utf8: bool = False) -> None: for name, meta in self._root_table._materialized_cols.items(): if meta.get("stale", False) and self._expression_references_name(expr, name): raise ValueError( @@ -12671,9 +12685,11 @@ def _guard_scalar_expression(self, expr: str) -> None: "support scalar columns. Use an element projection or a row-wise reduction first." ) if self._is_utf8_column(col) and self._expression_references_name(expr, col.name): + if allow_utf8: + continue raise NotImplementedError( f"Column {col.name!r} is a variable-length utf8 column; " - "string expressions on utf8 columns are not supported yet." + "string expressions on utf8 columns are not supported here." ) if self._is_varlen_scalar_column(col) and self._expression_references_name(expr, col.name): raise NotImplementedError( @@ -12681,8 +12697,123 @@ def _guard_scalar_expression(self, expr: str) -> None: "lazy expressions are not supported yet." ) - def _guard_varlen_scalar_expression(self, expr: str) -> None: - self._guard_scalar_expression(expr) + def _guard_varlen_scalar_expression(self, expr: str, *, allow_utf8: bool = False) -> None: + self._guard_scalar_expression(expr, allow_utf8=allow_utf8) + + # ------------------------------------------------------------------ + # utf8 string expressions: span-loop driver + # ------------------------------------------------------------------ + + #: Rows materialized per span by the utf8 expression driver. Matches the + #: chunk size of :meth:`Column._utf8_chunked_bool`. + _UTF8_EXPR_SPAN = 65536 + + #: Byte ceiling for one span's fixed-width `` list[str]: + """utf8 column names referenced by *expr*, in schema order. + + Call this on the *original* expression, before the dictionary/nested + rewrites: a nested utf8 leaf is aliased away by + :meth:`_rewrite_nested_expression` and would no longer be findable. + """ + return [ + col.name + for col in self._schema.columns + if self._is_utf8_column(col) and self._expression_references_name(expr, col.name) + ] + + def _lazyexpr_over_cols(self, expr: str, operands: dict, utf8_names: list[str]): + """``blosc2.lazyexpr(expr, operands)``, or the utf8 span driver. + + A variable-length utf8 column cannot be an expression operand: its + offsets and data live in separate NDArrays with independent chunk + grids, so the prefilter contract does not apply. When *expr* touches + one, evaluate it span by span instead, materializing each span to a + fixed-width `` out.dtype.itemsize: + out = out.astype(res.dtype) + out[start:stop] = res + if out is None: # empty table + out = np.zeros(n_phys, dtype=np.bool_) + return out def _is_nullable_column(self, name: str) -> bool: col = self[name] @@ -12801,11 +12932,12 @@ def where( # noqa: C901 if isinstance(expr_result, ColExpr): expr_result = expr_result._bind(self) if isinstance(expr_result, str): - self._guard_varlen_scalar_expression(expr_result) + self._guard_varlen_scalar_expression(expr_result, allow_utf8=True) + utf8_names = self._utf8_names_in(expr_result) operands = self._where_expression_operands(expr_result) expr_result, operands = self._rewrite_dictionary_predicates(expr_result, operands) expr_result, operands = self._rewrite_nested_expression(expr_result, operands) - expr_result = blosc2.lazyexpr(expr_result, operands) + expr_result = self._lazyexpr_over_cols(expr_result, operands, utf8_names) if isinstance(expr_result, np.ndarray) and expr_result.dtype == np.bool_: expr_result = blosc2.asarray(expr_result) if isinstance(expr_result, Column): diff --git a/src/blosc2/utf8_array.py b/src/blosc2/utf8_array.py index 8c1d9a4a3..0ae4fac81 100644 --- a/src/blosc2/utf8_array.py +++ b/src/blosc2/utf8_array.py @@ -251,6 +251,27 @@ def _read_persisted_span(self, a: int, b: int) -> np.ndarray: out[i] = blob[rel[i] : rel[i + 1]].decode("utf-8") return out + def _span_max_bytes(self, a: int, b: int) -> int: + """Longest UTF-8 byte length among rows ``[a, b)``. + + Read from the offsets alone -- no row is decoded. A byte length bounds + the codepoint count, so callers sizing a fixed-width ``U`` buffer can + use this directly. + """ + b = min(b, len(self)) + if b <= a: + return 0 + np_rows = self._persisted_rows + widest = 0 + if a < np_rows: + offs = np.asarray(self._offsets[a : min(b, np_rows) + 1], dtype=np.int64) + if offs.size > 1: + widest = int(np.diff(offs).max()) + if b > np_rows: + pending = self._pending[max(0, a - np_rows) : b - np_rows] + widest = max(widest, max((len(s.encode("utf-8")) for s in pending), default=0)) + return widest + def _gather_persisted(self, indices: np.ndarray) -> np.ndarray: """Gather persisted rows at *indices* (any order) as a StringDType array. @@ -658,6 +679,38 @@ def copy(self, spec=None, **kwargs: Any) -> Utf8Array: return out +def utf8_array(seq, spec=None, **kwargs) -> Utf8Array: + """Build a :class:`Utf8Array` from an iterable of strings. + + Parameters + ---------- + seq: + Iterable of ``str`` (or ``None`` for a nullable *spec*). + spec: + The :class:`~blosc2.schema.Utf8Spec` describing the array. Defaults + to ``blosc2.utf8()`` (non-nullable). + kwargs: + Forwarded to :class:`Utf8Array` (``offsets``, ``data``). + + Returns + ------- + Utf8Array + + Examples + -------- + >>> import blosc2 + >>> arr = blosc2.utf8_array(["hello", "café", "日本語"]) + >>> str(arr[1]) + 'café' + """ + import blosc2 + + arr = Utf8Array(spec if spec is not None else blosc2.utf8(), **kwargs) + arr.extend(seq) + arr.flush() + return arr + + class Utf8Factorizer: """Incremental factorizer over a :class:`Utf8Array`'s rows. diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index fa2089957..ff1ec86a6 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -10,6 +10,7 @@ from __future__ import annotations +import sys from dataclasses import dataclass import numpy as np @@ -255,7 +256,7 @@ def force_kernel_mode(request, monkeypatch): pure-Python per-row fallback, so the fallback stays covered even on a build where the compiled extension is available.""" if request.param == "fallback": - monkeypatch.setattr("blosc2.utf8_array._pack_utf8_kernel", lambda: None) + monkeypatch.setattr(sys.modules["blosc2.utf8_array"], "_pack_utf8_kernel", lambda: None) return request.param @@ -336,7 +337,7 @@ def force_write_kernel_mode(request, monkeypatch): join+encode fallback, so the fallback stays covered even on a build where the compiled extension is available.""" if request.param == "fallback": - monkeypatch.setattr("blosc2.utf8_array._encode_utf8_kernel", lambda: None) + monkeypatch.setattr(sys.modules["blosc2.utf8_array"], "_encode_utf8_kernel", lambda: None) return request.param @@ -1172,12 +1173,6 @@ def test_ctable_utf8_sort_non_ascii(): # --------------------------------------------------------------------------- -def test_ctable_utf8_where_expression_raises_clearly(): - t = make_table() - with pytest.raises(NotImplementedError, match="utf8"): - t.where("name == 'hello'") - - def test_ctable_utf8_create_index_raises_clearly(): t = make_table() with pytest.raises(NotImplementedError, match="utf8"): @@ -1281,3 +1276,170 @@ def test_utf8_duckdb_query(): "SELECT name, count(*) AS n FROM arrow_tbl WHERE name = 'paris' GROUP BY name" ).fetchall() assert result == [("paris", 2)] + + +# --------------------------------------------------------------------------- +# utf8_array() constructor +# --------------------------------------------------------------------------- + + +def test_utf8_array_constructor(): + arr = blosc2.utf8_array(SAMPLE) + assert isinstance(arr, blosc2.Utf8Array) + assert len(arr) == len(SAMPLE) + assert list(arr[:]) == SAMPLE + + +def test_utf8_array_constructor_with_spec_and_nulls(): + arr = blosc2.utf8_array(["a", None, "c"], blosc2.utf8(nullable=True, null_value="")) + assert list(arr[:]) == ["a", "", "c"] + + +def test_utf8_array_constructor_rejects_none_without_nullable_spec(): + with pytest.raises(TypeError, match="not nullable"): + blosc2.utf8_array(["a", None]) + + +def test_utf8_array_span_max_bytes_reads_only_offsets(): + arr = blosc2.utf8_array(["a", "café", "日本語"]) # 1, 5 and 9 UTF-8 bytes + assert arr._span_max_bytes(0, 3) == 9 + assert arr._span_max_bytes(0, 2) == 5 + assert arr._span_max_bytes(0, 0) == 0 + # Pending (unflushed) rows are measured too. + arr.append("x" * 20) + assert arr._span_max_bytes(0, 4) == 20 + + +# --------------------------------------------------------------------------- +# String expressions over utf8 columns (span-loop driver) +# --------------------------------------------------------------------------- + + +def test_ctable_utf8_where_expression_equality(): + t = make_table(["hello", "help", "world", "café"]) + assert list(t.where("name == 'hello'")["name"][:]) == ["hello"] + assert list(t.where("name != 'hello'")["name"][:]) == ["help", "world", "café"] + + +def test_ctable_utf8_where_expression_matches_operator_form(): + t = make_table(["paris", "london", "tokyo", "paris"]) + for value in ("paris", "tokyo", "absent"): + expr = list(t.where(f"name == '{value}'")["x"][:]) + operator = list(t[t.name == value]["x"][:]) + assert expr == operator, value + + +def test_ctable_utf8_where_expression_predicates(): + t = make_table(["hello", "help", "world"]) + assert list(t.where("startswith(name, 'hel')")["name"][:]) == ["hello", "help"] + assert list(t.where("endswith(name, 'lo')")["name"][:]) == ["hello"] + assert list(t.where("contains(name, 'l')")["name"][:]) == ["hello", "help", "world"] + + +def test_ctable_utf8_where_expression_mixes_with_numeric_columns(): + t = make_table(["a", "b", "c", "d"]) + assert list(t.where("(name == 'b') | (x > 2)")["name"][:]) == ["b", "d"] + assert list(t.where("(name != 'a') & (x < 2)")["name"][:]) == ["b"] + + +def test_ctable_utf8_where_expression_runs_on_miniexpr(): + """A silent NumPy fallback would produce the same values, so pin the engine. + + ``strict_miniexpr`` raises rather than falling back, which is the only + assertion that distinguishes the two. + """ + t = make_table(["hello", "help", "world"]) + got = t._utf8_span_eval("startswith(name, 'hel')", {}, ["name"], strict=True) + assert list(got[:3]) == [True, True, False] + + +def test_ctable_utf8_where_expression_spans_many_widths(): + # Exercises the power-of-two width bucketing: values straddle several + # buckets and one of them is past the 255-byte typesize cap. + values = ["a", "bb", "x" * 40, "y" * 300, "café", ""] * 30 + t = make_table(values) + assert list(t.where("name == 'café'")["x"][:]) == [i for i, v in enumerate(values) if v == "café"] + assert list(t.where("startswith(name, 'y')")["x"][:]) == [ + i for i, v in enumerate(values) if v.startswith("y") + ] + + +def test_ctable_utf8_where_expression_splits_oversized_spans(): + # A single long row would size the whole span's 1] + assert list(view.where("name == 'paris'")["x"][:]) == [2] + + +# --------------------------------------------------------------------------- +# Null policy (3c): nulls are materialized to "" and re-masked afterwards +# --------------------------------------------------------------------------- + + +def _nullable_table(values): + return CTable( + NullableRow, + new_data={"name": list(values), "x": list(range(len(values)))}, + ) + + +def test_ctable_utf8_where_expression_nulls_never_match(): + t = _nullable_table(["hello", None, "help", None, "world"]) + assert list(t.where("name == 'hello'")["x"][:]) == [0] + assert list(t.where("startswith(name, 'hel')")["x"][:]) == [0, 2] + # Not even against the sentinel string itself: a null is not a value. + assert list(t.where("name == ''")["x"][:]) == [] + + +def test_ctable_utf8_where_expression_nulls_match_operator_form(): + t = _nullable_table(["hello", None, "help", None, "world"]) + for value in ("hello", "world", ""): + assert list(t.where(f"name == '{value}'")["x"][:]) == list(t[t.name == value]["x"][:]) + assert list(t.where(f"name != '{value}'")["x"][:]) == list(t[t.name != value]["x"][:]) + + +def test_ctable_utf8_where_expression_all_null_column(): + t = _nullable_table([None] * 5) + assert list(t.where("name == 'hello'")["x"][:]) == [] + assert list(t.where("name != 'hello'")["x"][:]) == [] + + +def test_ctable_utf8_where_expression_null_count_zero_fast_path(): + # A nullable column with no actual nulls must not mask anything away. + t = _nullable_table(["hello", "help", "world"]) + assert list(t.where("startswith(name, 'hel')")["x"][:]) == [0, 1] + + +def test_ctable_utf8_sum_where_expression(): + t = make_table(["hello", "help", "world"]) + assert t["x"].sum(where="startswith(name, 'hel')") == 1 From 332ac400484c8cfb90e3708a3e9731f4121fdd03 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 06:54:17 +0200 Subject: [PATCH 09/86] docs: utf8() no longer disclaims string-expression filters They land with the span-loop driver; create_index() is the one remaining utf8 limitation. Co-Authored-By: Claude Opus 5 --- src/blosc2/schema.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/blosc2/schema.py b/src/blosc2/schema.py index ad77e1a44..e083e4092 100644 --- a/src/blosc2/schema.py +++ b/src/blosc2/schema.py @@ -847,14 +847,13 @@ def utf8(*, nullable: bool = False, null_value: str | None = None) -> Utf8Spec: inferred automatically from plain ``str`` annotations. utf8 columns support vectorized comparisons (``==``, ``!=``, ``<``, - ``<=``, ``>``, ``>=``), :meth:`CTable.group_by` keys, - :meth:`CTable.sort_by`, and Arrow/Parquet interop. Current limitations: - :meth:`CTable.create_index` is not supported yet (use a fixed-width - :class:`string` column if you need an index), and string-*expression* - filters such as ``t.where("name == 'x'")`` are not supported yet — use - the operator form ``t[t.name == 'x']`` instead. See - :ref:`ChoosingStringType` for a full comparison with :class:`string` - and :func:`vlstring`. + ``<=``, ``>``, ``>=``), string-expression filters such as + ``t.where("name == 'x'")`` and ``t.where("startswith(name, 'x')")``, + :meth:`CTable.group_by` keys, :meth:`CTable.sort_by`, and Arrow/Parquet + interop. Current limitation: :meth:`CTable.create_index` is not + supported yet (use a fixed-width :class:`string` column if you need an + index). See :ref:`ChoosingStringType` for a full comparison with + :class:`string` and :func:`vlstring`. Parameters ---------- From 2638a0dd95585291114d832a037b7170a1b263ff Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 07:40:27 +0200 Subject: [PATCH 10/86] Rename the utf8_array module to _utf8_array Exporting blosc2.utf8_array() as the public Utf8Array constructor shadowed the module of the same name. `from blosc2.utf8_array import X` still resolved (the import machinery finds the submodule), but attribute-path lookups landed on the function, which broke two monkeypatch call sites in the tests and would have confused anyone reading `blosc2.utf8_array` in a traceback. The module was always internal -- every reference to it is inside blosc2 or its tests -- so the leading underscore states that and frees the plain name for the constructor. The monkeypatch sites go back to the plain attribute-path form. No public API change: blosc2.utf8_array (function), blosc2.Utf8Array and `from blosc2 import ...` are unaffected. Co-Authored-By: Claude Opus 5 --- src/blosc2/__init__.py | 2 +- src/blosc2/{utf8_array.py => _utf8_array.py} | 0 src/blosc2/ctable.py | 12 +++--- src/blosc2/ctable_storage.py | 2 +- src/blosc2/schema.py | 2 +- tests/ctable/test_dictionary_column.py | 2 +- tests/ctable/test_utf8.py | 43 ++++++++++---------- 7 files changed, 31 insertions(+), 32 deletions(-) rename src/blosc2/{utf8_array.py => _utf8_array.py} (100%) diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index 915f9195f..dc02a0569 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -567,7 +567,7 @@ def _raise(exc): from .tree_store import TreeStore from .batch_array import Batch, BatchArray from .list_array import ListArray -from .utf8_array import Utf8Array, utf8_array +from ._utf8_array import Utf8Array, utf8_array from .objectarray import ObjectArray, objectarray_from_cframe from .ref import Ref from .b2objects import open_b2object diff --git a/src/blosc2/utf8_array.py b/src/blosc2/_utf8_array.py similarity index 100% rename from src/blosc2/utf8_array.py rename to src/blosc2/_utf8_array.py diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 8a7a0b7b5..78a09eff5 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -2040,7 +2040,7 @@ def _utf8_chunked_bool(self, fn, *, chunk_size: int = 65536) -> np.ndarray: """Apply ``fn(chunk, start, stop)`` over this utf8 column's logical rows. *fn* returns a boolean array for each ``StringDType`` chunk read from - the underlying :class:`~blosc2.utf8_array.Utf8Array`. Returns a + the underlying :class:`~blosc2._utf8_array.Utf8Array`. Returns a physical-length (``_valid_rows``-length) boolean NumPy array; rows beyond the column's logical length are left ``False``. """ @@ -2057,7 +2057,7 @@ def _utf8_chunked_bytes(self, fn, *, chunk_size: int = 65536) -> np.ndarray: """Apply ``fn(arr, start, stop)`` over this utf8 column's logical rows. Like :meth:`_utf8_chunked_bool`, but *fn* operates directly on the - underlying :class:`~blosc2.utf8_array.Utf8Array` (raw offsets/bytes) + underlying :class:`~blosc2._utf8_array.Utf8Array` (raw offsets/bytes) instead of a materialized ``StringDType`` chunk, so no per-row decode happens. Returns a physical-length boolean NumPy array; rows beyond the column's logical length are left ``False``. @@ -2118,8 +2118,8 @@ def fn(chunk, start, stop): def _utf8_compare_scalar(self, numpy_op, value: str): """Scalar comparison, evaluated chunk by chunk directly on raw UTF-8 bytes (no decode to ``StringDType``) via - :meth:`~blosc2.utf8_array.Utf8Array.equal_mask_span` / - :meth:`~blosc2.utf8_array.Utf8Array.order_masks_span`. + :meth:`~blosc2._utf8_array.Utf8Array.equal_mask_span` / + :meth:`~blosc2._utf8_array.Utf8Array.order_masks_span`. """ nv = self.null_value @@ -7070,7 +7070,7 @@ def _arrow_type_to_spec( # noqa: C901 if _is_arrow_string_type(pa, pa_type): if string_max_length is None: - from blosc2.utf8_array import have_string_dtype + from blosc2._utf8_array import have_string_dtype if not have_string_dtype(): # utf8 columns need numpy.dtypes.StringDType (NumPy >= 2.0). @@ -7141,7 +7141,7 @@ def _compiled_columns_from_arrow( # only binary columns keep the native-None varlen treatment. # On NumPy < 2.0 (no StringDType) utf8 columns are unavailable and # scalar strings keep the historical vlstring treatment instead. - from blosc2.utf8_array import have_string_dtype + from blosc2._utf8_array import have_string_dtype field_is_varlen_scalar = ( not field_is_list diff --git a/src/blosc2/ctable_storage.py b/src/blosc2/ctable_storage.py index 96359498c..4003ec370 100644 --- a/src/blosc2/ctable_storage.py +++ b/src/blosc2/ctable_storage.py @@ -28,6 +28,7 @@ import numpy as np import blosc2 +from blosc2._utf8_array import Utf8Array, _new_backend_arrays from blosc2.batch_array import BatchArray from blosc2.dictionary_column import DictionaryColumn from blosc2.list_array import ListArray @@ -39,7 +40,6 @@ ) from blosc2.schema import Utf8Spec from blosc2.schunk import process_opened_object -from blosc2.utf8_array import Utf8Array, _new_backend_arrays if TYPE_CHECKING: from blosc2.schema import ListSpec diff --git a/src/blosc2/schema.py b/src/blosc2/schema.py index e083e4092..79b9007c5 100644 --- a/src/blosc2/schema.py +++ b/src/blosc2/schema.py @@ -872,7 +872,7 @@ def utf8(*, nullable: bool = False, null_value: str | None = None) -> Utf8Spec: ... name: str = b2.field(b2.utf8()) ... note: str = b2.field(b2.utf8(nullable=True)) """ - from blosc2.utf8_array import string_dtype + from blosc2._utf8_array import string_dtype string_dtype() # fail early with a clear error on NumPy < 2.0 return Utf8Spec(nullable=nullable, null_value=null_value) diff --git a/tests/ctable/test_dictionary_column.py b/tests/ctable/test_dictionary_column.py index 542c2f268..071357d4c 100644 --- a/tests/ctable/test_dictionary_column.py +++ b/tests/ctable/test_dictionary_column.py @@ -506,7 +506,7 @@ def test_cli_decode_dictionaries_flag(tmp_path): assert main(["--decode-dictionaries", str(path), str(out)]) == 0 ct = CTable.open(str(out), mode="r") - from blosc2.utf8_array import have_string_dtype + from blosc2._utf8_array import have_string_dtype # Decoded strings become utf8 columns on NumPy >= 2.0, vlstring on older NumPy. expected_spec = Utf8Spec if have_string_dtype() else VLStringSpec diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index ff1ec86a6..18d49a548 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -10,7 +10,6 @@ from __future__ import annotations -import sys from dataclasses import dataclass import numpy as np @@ -111,7 +110,7 @@ class Plain: def test_utf8_array_basic_roundtrip(): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) arr.extend(SAMPLE) @@ -125,7 +124,7 @@ def test_utf8_array_basic_roundtrip(): def test_utf8_array_reads_across_pending_boundary(): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) arr.extend(SAMPLE[:4]) @@ -142,7 +141,7 @@ def test_utf8_array_reads_across_pending_boundary(): def test_utf8_array_setitem_shifts_offsets(): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) arr.extend(["aa", "bb", "cc"]) @@ -154,7 +153,7 @@ def test_utf8_array_setitem_shifts_offsets(): def test_utf8_array_rejects_non_str(): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) with pytest.raises(TypeError, match="Expected str"): @@ -169,7 +168,7 @@ def test_utf8_array_rejects_non_str(): def test_utf8_array_extend_empty_iterable_is_noop(): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) arr.extend([]) @@ -185,7 +184,7 @@ def test_utf8_array_extend_many_rows_no_dropped_rows(): `self._pending` to a fresh list rather than mutating it, so an `extend()` spanning several internal flushes must re-read `self._pending` after each one instead of caching a reference.""" - from blosc2.utf8_array import _FLUSH_ROWS, Utf8Array + from blosc2._utf8_array import _FLUSH_ROWS, Utf8Array n = _FLUSH_ROWS * 3 + 7 values = [f"row{i}" for i in range(n)] @@ -198,7 +197,7 @@ def test_utf8_array_extend_many_rows_no_dropped_rows(): def test_utf8_array_extend_none_straddles_chunk_boundary(): - from blosc2.utf8_array import _FLUSH_ROWS, Utf8Array + from blosc2._utf8_array import _FLUSH_ROWS, Utf8Array values = [f"v{i}" for i in range(_FLUSH_ROWS + 2)] values[_FLUSH_ROWS - 1] = None # last row of first chunk @@ -210,7 +209,7 @@ def test_utf8_array_extend_none_straddles_chunk_boundary(): def test_utf8_array_extend_append_interleaved_before_flush(): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) arr.append("first") @@ -221,7 +220,7 @@ def test_utf8_array_extend_append_interleaved_before_flush(): def test_utf8_array_extend_ascii_nul_byte_preserved(): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array values = ["nul\x00in", "plain", "\x00leading", "trailing\x00"] assert all(v.isascii() for v in values) @@ -236,7 +235,7 @@ def test_utf8_array_extend_multi_mb_strings_bounded_flush(): per _FLUSH_ROWS-sized chunk (not per row), so this overshoots _FLUSH_CHARS by at most one chunk before flushing -- confirm read-back is still correct despite the coarser check.""" - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array values = [f"{i:06d}" + "x" * (2 * 1024 * 1024) for i in range(20)] arr = Utf8Array(blosc2.utf8()) @@ -256,7 +255,7 @@ def force_kernel_mode(request, monkeypatch): pure-Python per-row fallback, so the fallback stays covered even on a build where the compiled extension is available.""" if request.param == "fallback": - monkeypatch.setattr(sys.modules["blosc2.utf8_array"], "_pack_utf8_kernel", lambda: None) + monkeypatch.setattr("blosc2._utf8_array._pack_utf8_kernel", lambda: None) return request.param @@ -283,7 +282,7 @@ def test_pack_utf8_span_rejects_malformed_rel(): def test_utf8_array_bulk_read_kernel_and_fallback(force_kernel_mode): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) arr.extend(SAMPLE) @@ -296,7 +295,7 @@ def test_utf8_array_bulk_read_kernel_and_fallback(force_kernel_mode): def test_utf8_array_bulk_read_matches_python_ground_truth(force_kernel_mode): """A wider mix of byte lengths and edge cases than SAMPLE: many distinct ASCII/multi-byte/empty/NUL-bearing values, read back in one bulk span.""" - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array rng = np.random.default_rng(5) pool = ["", "a", "café", "日本語", "x" * 5000, "nul\x00in", "nul\x00INSIDE", "emoji 🎉🚀"] @@ -337,12 +336,12 @@ def force_write_kernel_mode(request, monkeypatch): join+encode fallback, so the fallback stays covered even on a build where the compiled extension is available.""" if request.param == "fallback": - monkeypatch.setattr(sys.modules["blosc2.utf8_array"], "_encode_utf8_kernel", lambda: None) + monkeypatch.setattr("blosc2._utf8_array._encode_utf8_kernel", lambda: None) return request.param def test_utf8_array_extend_kernel_and_fallback(force_write_kernel_mode): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) arr.extend(SAMPLE) @@ -353,7 +352,7 @@ def test_utf8_array_extend_kernel_and_fallback(force_write_kernel_mode): def test_utf8_array_extend_matches_python_ground_truth(force_write_kernel_mode): """Same wider mix of byte lengths and edge cases as the read-side ground-truth test, exercised through the write path this time.""" - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array rng = np.random.default_rng(7) pool = ["", "a", "café", "日本語", "x" * 5000, "nul\x00in", "nul\x00INSIDE", "emoji 🎉🚀"] @@ -365,7 +364,7 @@ def test_utf8_array_extend_matches_python_ground_truth(force_write_kernel_mode): def test_utf8_array_extend_ascii_nul_byte_kernel_and_fallback(force_write_kernel_mode): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array values = ["nul\x00in", "plain", "\x00leading", "trailing\x00"] arr = Utf8Array(blosc2.utf8()) @@ -377,7 +376,7 @@ def test_utf8_array_extend_ascii_nul_byte_kernel_and_fallback(force_write_kernel def test_utf8_array_extend_multi_mb_string_kernel_and_fallback(force_write_kernel_mode): """A single multi-MB value alongside short ones -- sanity-checks the total-length/offset accumulation in the compiled kernel's two passes.""" - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array values = ["head", "x" * (8 * 1024 * 1024), "tail", "café" * 100_000] arr = Utf8Array(blosc2.utf8()) @@ -396,7 +395,7 @@ def test_utf8_array_extend_lone_surrogate_raises_and_recovers(force_write_kernel UnicodeEncodeError, matching str.encode('utf-8')'s own behavior, and the array must remain usable afterwards -- a regression test for the compiled kernel's temp-buffer cleanup on the error path.""" - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) arr.extend(["first"]) @@ -865,7 +864,7 @@ def test_utf8_factorize_span_matches_np_unique_contract(): numpy's np.unique on StringDType merges strings differing only after an embedded NUL (numpy bug), which the byte-exact factorization does not. """ - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array rng = np.random.default_rng(7) pool = ["", "a", "ab", "café", "日本語", "x" * 3000, "nul\x00in", "nul\x00IN", "Wien", "wien"] @@ -878,7 +877,7 @@ def test_utf8_factorize_span_matches_np_unique_contract(): def test_utf8_factorizer_cross_span_codes_are_global(): - from blosc2.utf8_array import Utf8Array + from blosc2._utf8_array import Utf8Array arr = Utf8Array(blosc2.utf8()) arr.extend(["b", "a", "b", "c", "a", "d"]) From 47b033c2476c0ad413ec7d0811b331837cb50f2e Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 07:50:22 +0200 Subject: [PATCH 11/86] Phase 4: answer utf8 scalar predicates with a raw-byte scan The plan gated Phase 4 (a native ME_UTF8 input dtype in miniexpr) on measurement, because its competition is not the span driver's decode but the raw-byte comparison python-blosc2 already has. Measured, and the gate says do not build it: 1M rows, 'literal'` terms (both operand orders, all six comparisons) are answered by the raw-byte scan and substituted into the expression as boolean operands, mirroring the _rewrite_dictionary_predicates pass that already sits next to it. A utf8 name drops out only when every one of its occurrences was rewritten, so startswith/contains/upper still route to the span driver, and a mixed expression rewrites the half it can. 1M rows short 156.5 -> 28.2 ms (5.5x) 1M rows medium 267.5 -> 56.3 ms (4.8x) 200k rows long 97.7 -> 16.0 ms (6.1x) The expression form now matches the operator form, which was Phase 4's target. Tests assert the route taken, not just the answer, since correctness alone cannot tell the two apart. Co-Authored-By: Claude Opus 5 --- src/blosc2/ctable.py | 92 ++++++++++++++++++++++++++++++++++----- tests/ctable/test_utf8.py | 85 ++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 10 deletions(-) diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 78a09eff5..87f7dc5ce 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -15,6 +15,7 @@ import contextvars import copy import dataclasses +import itertools import json import operator import os @@ -26,7 +27,7 @@ from dataclasses import MISSING, dataclass from dataclasses import field as dataclass_field from textwrap import TextWrapper -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, TypeVar import numpy as np @@ -2115,11 +2116,14 @@ def fn(chunk, start, stop): raw = self._utf8_chunked_bool(fn) return blosc2.asarray(raw) & self._lazy_valid_rows() - def _utf8_compare_scalar(self, numpy_op, value: str): - """Scalar comparison, evaluated chunk by chunk directly on raw UTF-8 - bytes (no decode to ``StringDType``) via + def _utf8_scalar_mask(self, numpy_op, value: str) -> np.ndarray: + """Raw physical-length boolean mask for ``column value``. + + Compares raw UTF-8 bytes with no decode to ``StringDType``, via :meth:`~blosc2._utf8_array.Utf8Array.equal_mask_span` / - :meth:`~blosc2._utf8_array.Utf8Array.order_masks_span`. + :meth:`~blosc2._utf8_array.Utf8Array.order_masks_span`. A null never + satisfies any comparison. Not intersected with the live-row mask -- + see :meth:`_utf8_compare_scalar` for that. """ nv = self.null_value @@ -2148,8 +2152,11 @@ def fn(arr, start, stop): res = res & ~arr.equal_mask_span(nv, start, stop) return res - raw = self._utf8_chunked_bytes(fn) - return blosc2.asarray(raw) & self._lazy_valid_rows() + return self._utf8_chunked_bytes(fn) + + def _utf8_compare_scalar(self, numpy_op, value: str): + """Scalar comparison as a live-row-intersected boolean NDArray.""" + return blosc2.asarray(self._utf8_scalar_mask(numpy_op, value)) & self._lazy_valid_rows() def _dictionary_eq(self, other): """Return a physical-slot boolean predicate for dictionary equality. @@ -12742,14 +12749,76 @@ def _utf8_names_in(self, expr: str) -> list[str]: if self._is_utf8_column(col) and self._expression_references_name(expr, col.name) ] + #: Comparisons a utf8 column supports against a string literal, longest + #: spelling first so ``<=`` is not matched as ``<``. + _UTF8_CMP_OPS: ClassVar[dict] = { + "==": np.equal, + "!=": np.not_equal, + "<=": np.less_equal, + ">=": np.greater_equal, + "<": np.less, + ">": np.greater, + } + #: Mirrored operator for a reversed comparison (``'x' < name``). + _UTF8_CMP_MIRROR: ClassVar[dict] = {"==": "==", "!=": "!=", "<=": ">=", ">=": "<=", "<": ">", ">": "<"} + + def _rewrite_utf8_predicates( + self, expr: str, operands: dict, utf8_names: list[str] + ) -> tuple[str, dict, list[str]]: + """Replace ``utf8col 'literal'`` terms with precomputed masks. + + A scalar comparison is answered by a raw-byte scan of the offsets/data + pair (:meth:`Column._utf8_scalar_mask`) with no decode at all, which is + several times cheaper than the span driver's decode -> `` + miniexpr round trip and is exactly what the operator form + ``t[t.name == "x"]`` already does. Substituting the mask as a boolean + operand keeps the rest of the expression (``&``/``|``, numeric terms) a + single native expression. + + Returns the rewritten expression, the extended operands, and the utf8 + names still referenced -- a name drops out only when *every* one of its + occurrences was rewritten, so anything else (``startswith(name, 'x')``, + ``upper(name)``) still routes to the span driver. + """ + rewritten = expr + new_operands = dict(operands) + remaining = [] + ops = "|".join(re.escape(o) for o in self._UTF8_CMP_OPS) + for i, name in enumerate(utf8_names): + column = self[name] + counter = itertools.count() + + def repl(match: re.Match, _col=column, _i=i, _c=counter, reverse=False) -> str: + op = match.group(1) + literal = ast.literal_eval(match.group(2) if not reverse else match.group(1)) + if reverse: + op = self._UTF8_CMP_MIRROR[match.group(2)] + alias = f"__u8{_i}_{next(_c)}" + new_operands[alias] = blosc2.asarray(_col._utf8_scalar_mask(self._UTF8_CMP_OPS[op], literal)) + return alias + + escaped = r"(? 'm'", lambda v: v > "m"), + ("name >= 'help'", lambda v: v >= "help"), + ("'help' == name", lambda v: v == "help"), + ("'help' != name", lambda v: v != "help"), + ("'m' > name", lambda v: v < "m"), + ("'m' <= name", lambda v: v >= "m"), + ], +) +def test_ctable_utf8_scalar_predicates_match_python(expr, predicate): + values = ["hello", "help", "world", "café", "日本語", "", "zz"] + t = make_table(values) + assert list(t.where(expr)["x"][:]) == [i for i, v in enumerate(values) if predicate(v)] + + +@pytest.mark.parametrize( + ("expr", "rewritten_away"), + [ + ("name == 'help'", True), + ("'help' == name", True), + ("name < 'm'", True), + ("(name == 'help') | (x > 4)", True), + ("(name == 'a') & (name != 'b')", True), + # Not a scalar comparison: these still need the span driver. + ("startswith(name, 'hel')", False), + ("contains(name, 'l')", False), + ("startswith(name, 'hel') | (name == 'zz')", False), + ], +) +def test_ctable_utf8_scalar_predicates_skip_the_span_driver(expr, rewritten_away): + """The raw-byte scan is several times cheaper than decode -> miniexpr. + + Correctness alone would not notice the difference, so assert on which route + the expression takes: a utf8 name survives the rewrite only when something + other than a scalar comparison still references it. + """ + t = make_table(["hello", "help", "world", "zz", "a", "b"]) + operands = t._where_expression_operands(expr) + _, _, remaining = t._rewrite_utf8_predicates(expr, operands, t._utf8_names_in(expr)) + assert (remaining == []) is rewritten_away + + +def test_ctable_utf8_rewritten_predicate_matches_span_driver(): + """Both routes must agree, including on nulls and on the sentinel spelling.""" + values = ["hello", None, "help", None, "world"] + t = _nullable_table(values) + for expr in ("name == 'hello'", "name != 'hello'", "name < 'm'", "name == ''"): + fast = list(t.where(expr)["x"][:]) + slow = list(np.flatnonzero(t._utf8_span_eval(expr, {}, ["name"])[: len(values)])) + assert fast == slow, expr + + +def test_ctable_utf8_scalar_predicate_literal_with_operator_chars(): + # The literal is parsed with ast.literal_eval, so quoted operators and + # spaces inside it must not be mistaken for expression syntax. + values = ["a == b", "x > y", "plain"] + t = make_table(values) + assert list(t.where("name == 'a == b'")["x"][:]) == [0] + assert list(t.where("name == 'x > y'")["x"][:]) == [1] + + +def test_ctable_utf8_scalar_predicate_on_view_and_after_delete(): + t = make_table(["paris", "london", "paris", "tokyo"]) + t.delete([0]) + assert list(t.where("name == 'paris'")["x"][:]) == [2] + view = t[t.x > 1] + assert list(view.where("name == 'paris'")["x"][:]) == [2] + + +def test_ctable_utf8_two_scalar_predicates_on_the_same_column(): + t = make_table(["a", "b", "c", "d"]) + assert list(t.where("(name > 'a') & (name < 'd')")["name"][:]) == ["b", "c"] From cfd389808ed466701e83375c23012a24a9fcfaab Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 07:51:16 +0200 Subject: [PATCH 12/86] docs: release note for the utf8 scalar-predicate fast path Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 3398c2b02..098410086 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -32,6 +32,14 @@ XXX version-specific blurb XXX Nulls are materialized to `""` before any kernel sees them and re-masked afterwards, so a null never satisfies a predicate — the same answer the operator form gives. +- **Scalar comparisons on `utf8()` columns are 5-6x faster in expression + form.** `t.where("name == 'x'")` (and `!=`, `<`, `<=`, `>`, `>=`, either + operand order) is now answered by the same raw-byte scan the operator form + `t[t.name == "x"]` uses, instead of decoding the column to fixed-width + first: 156 -> 28 ms over 1M short values, 268 -> 56 ms over 1M ~31-byte + values. Mixed expressions get whatever they can -- in + `startswith(name, 'x') | (name == 'zz')` the comparison takes the fast + path and `startswith` still decodes. - **New `blosc2.utf8_array(seq, spec=None)`** builds a `Utf8Array` from an iterable of strings; `Utf8Array` is exported too. Previously the only construction path was `Utf8Array(spec)` + `.extend()` + `.flush()`, which From 054629337acc428b017ec1b12fabb22b6d3e5ad2 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 08:11:33 +0200 Subject: [PATCH 13/86] Fix string indexes returning zero rows at the default column width `create_index()` on a string column silently made every query on it match nothing. No error, no warning -- adding an index, an optimization, changed the answer to zero rows: @dataclass class Row: name: str # -> string(max_length=32) -> 255: max_length 31 works, 32 does not. summary/bucket/partial/full were all affected; opsi survived because it does not read segment summaries. Convert the request through bytes using the typesize the chunk header records, as in cbcb15b5, and raise on a short read instead of leaving the tail of the destination uninitialised -- that silence is what made this cost a full investigation rather than showing up as an error. Co-Authored-By: Claude Opus 5 --- src/blosc2/blosc2_ext.pyx | 29 +++++++++++++- tests/ctable/test_ctable_indexing.py | 59 ++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 8c4b62573..0ab1928a2 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -3862,6 +3862,11 @@ cdef class NDArray: cdef int rc cdef int32_t lazychunk_cbytes cdef c_bool owns_dctx = False + cdef size_t header_typesize + cdef int header_flags + cdef int64_t span_start + cdef int32_t span_nitems + cdef int32_t want_nbytes lazychunk_cbytes = blosc2_schunk_get_lazychunk(self.array.sc, nchunk, &chunk, &needs_free) if lazychunk_cbytes < 0: @@ -3892,10 +3897,26 @@ cdef class NDArray: if needs_free: free(chunk) raise RuntimeError("Could not create decompression context") + # blosc2_getitem_ctx() counts in the typesize the *chunk header* records, + # which is not always the array's: c-blosc2 caps a typesize above + # BLOSC_MAX_TYPESIZE (255) to 1 so its split machinery keeps working. + # Asking in element units then silently reads a byte range instead -- + # an index summary over a start * self.array.sc.typesize) // header_typesize + span_nitems = want_nbytes // header_typesize # For lazy chunks, blosc2_cbuffer_sizes() only reports the header cbytes. # blosc2_getitem_ctx() needs the full lazy chunk size returned by # blosc2_schunk_get_lazychunk(). - rc = blosc2_getitem_ctx(dctx, chunk, lazychunk_cbytes, start, nitems, view.buf, view.len) + rc = blosc2_getitem_ctx(dctx, chunk, lazychunk_cbytes, span_start, span_nitems, + view.buf, view.len) if owns_dctx: blosc2_free_ctx(dctx) PyBuffer_Release(&view) @@ -3903,6 +3924,12 @@ cdef class NDArray: free(chunk) if rc < 0: raise RuntimeError("Error while decoding the requested span") + if rc != want_nbytes: + # A short read used to pass silently and leave the tail of the + # destination uninitialised. + raise RuntimeError( + f"decoded {rc} bytes for the requested span, expected {want_nbytes}" + ) return arr diff --git a/tests/ctable/test_ctable_indexing.py b/tests/ctable/test_ctable_indexing.py index ab2e01161..32dfd8f0f 100644 --- a/tests/ctable/test_ctable_indexing.py +++ b/tests/ctable/test_ctable_indexing.py @@ -1160,3 +1160,62 @@ def test_merge_segment_plans_intersection_union_and_fallback(): assert _merge_segment_plans(coarse, fine, "and") is fine # fine prunes more assert _merge_segment_plans(fine, coarse, "and") is fine assert _merge_segment_plans(coarse, fine, "or") is None + + +# --------------------------------------------------------------------------- +# String index summaries wider than 255 bytes +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("max_length", [16, 31, 32, 64, 100]) +@pytest.mark.parametrize("kind", ["summary", "bucket", "partial", "full", "opsi"]) +def test_string_index_matches_unindexed_scan(max_length, kind): + """An index must never change a query's answer. + + A segment summary is a ``(min, max, flags)`` record, so a ``= 'c10'", + "(name > 'c05') & (name <= 'c08')", + ] + expected = {q: sorted(int(v) for v in t.where(q)["x"][:]) for q in queries} + assert expected["name == 'c07'"], "fixture should match some rows" + + t.create_index(col_name="name", kind=blosc2.IndexKind(kind)) + for q in queries: + assert sorted(int(v) for v in t.where(q)["x"][:]) == expected[q], q + + +def test_wide_sidecar_span_read_is_not_short(): + """get_1d_span_numpy() must fill the whole destination, not part of it. + + A short read used to leave the tail uninitialised rather than raise. + """ + dtype = np.dtype([("min", " 255, "the point of this test is a capped typesize" + values = np.zeros(500, dtype=dtype) + values["min"] = [f"lo-{i:04d}" for i in range(500)] + values["max"] = [f"hi-{i:04d}" for i in range(500)] + values["flags"] = np.arange(500) % 251 + + arr = blosc2.asarray(values, chunks=(128,)) + out = np.empty(100, dtype=dtype) + arr.get_1d_span_numpy(out, 1, 5, 100) + assert out.tolist() == values[128 + 5 : 128 + 105].tolist() From c8fd3f5b0d6c35dc750dcd077404a838f460020a Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 08:11:44 +0200 Subject: [PATCH 14/86] docs: release note for the string-index zero-rows fix Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 098410086..642752a57 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -106,6 +106,17 @@ message when opening a nonexistent `CTable` in append mode. ### Bug fixes +- **`create_index()` on a string column made every query on it return zero + rows.** Silently -- adding an index, an optimization, changed the answer. + A segment summary is a `(min, max, flags)` record, so a ` 255` (31 works, 32 does not); `summary`, + `bucket`, `partial` and `full` indexes were affected, `opsi` was not. + A short span read now raises instead of leaving the destination partly + uninitialised. - **Expressions over operands wider than 255 bytes returned wrong results.** c-blosc2 caps a typesize above 255 to 1 in the chunk header so its split machinery keeps working, and the miniexpr prefilter was asking From 393a200478fb525673d17ddcd6652142bb2072f1 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 08:28:49 +0200 Subject: [PATCH 15/86] Sweep the capped-typesize family: one helper, plus SChunk.get_slice This defect surfaced three times in one session through three different callers, so give it a single home instead of a fourth copy. New getitem_span() converts an element-addressed span through bytes using the typesize the chunk header records, and every blosc2_getitem_ctx() call now goes through it: the miniexpr prefilter (cbcb15b5), the index sidecar reader (05462933), and the matmul prefilter -- the last unreachable today at typesize <= 8, routed through the helper so it does not become a latent trap. Callers now compare the decoded byte count against what they asked for, so a short read raises instead of leaving a buffer partly uninitialised. The sweep also turned up a fourth instance, this one upstream: blosc2_schunk_get_slice_buffer() derives the getitem for a partially covered chunk by dividing byte offsets by schunk->typesize, which the header contradicts above 255. Over 153 slice shapes at typesize 256, 150 raised "Error while getting the slice" and 3 -- the single-element ones -- returned the wrong bytes with no error. Reachable from ordinary data: an --- src/blosc2/blosc2_ext.pyx | 97 ++++++++++++++++++---------------- src/blosc2/schunk.py | 38 +++++++++++++ tests/test_schunk_get_slice.py | 46 ++++++++++++++++ 3 files changed, 136 insertions(+), 45 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 0ab1928a2..d47ccb214 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -2630,6 +2630,38 @@ cdef int general_filler(blosc2_prefilter_params *params): return 0 +cdef inline int getitem_span(blosc2_context* dctx, const uint8_t* chunk, int32_t chunk_cbytes, + int64_t start_item, int32_t nitems, int32_t typesize, + void* dest, int32_t destsize) nogil: + """``blosc2_getitem_ctx()`` addressed in elements of *typesize*. + + ``blosc2_getitem_ctx()`` counts in the typesize the *chunk header* + records, which is not always the array's: c-blosc2 caps a typesize above + BLOSC_MAX_TYPESIZE (255) to 1 so its split machinery keeps working + ("treat buffer as an 1-byte stream", blosc2.c). Asking in element units + then silently reads a byte range instead -- results were wrong, partly + uninitialised, and no error was raised anywhere. Convert through bytes: + identical arithmetic whenever the typesize is not capped. + + Returns the number of bytes decoded, a negative blosc2 error code, or + -1 when the header typesize is unusable. Callers should treat a return + other than ``nitems * typesize`` as a failure; a short read leaves the + tail of *dest* untouched. + """ + cdef size_t header_typesize + cdef int header_flags + cdef int64_t span_start + cdef int32_t span_nitems + + blosc1_cbuffer_metainfo(chunk, &header_typesize, &header_flags) + if header_typesize == 0: + return -1 + span_start = (start_item * typesize) // header_typesize + span_nitems = (nitems * typesize) // header_typesize + return blosc2_getitem_ctx(dctx, chunk, chunk_cbytes, span_start, span_nitems, + dest, destsize) + + # Auxiliary function for miniexpr as a prefilter # Only meant for (input and output) arrays that are blosc2.NDArray objects. cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, @@ -2660,10 +2692,6 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, cdef me_input_cache_s* input_cache cdef int32_t chunk_nbytes, chunk_cbytes, block_nbytes cdef int start, blocknitems, expected_blocknitems - cdef size_t header_typesize - cdef int header_flags - cdef int64_t getitem_start - cdef int32_t getitem_nitems cdef int64_t valid_nitems cdef int64_t global_block cdef int32_t input_typesize @@ -2836,17 +2864,6 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, expected_blocknitems = blocknitems elif blocknitems != expected_blocknitems: raise ValueError("miniexpr: inconsistent block element counts across inputs") - # blosc2_getitem_ctx() counts in the typesize the *chunk header* records, - # which is not always the array's: c-blosc2 caps a typesize above - # BLOSC_MAX_TYPESIZE (255) to 1 so its split machinery keeps working - # (blosc2.c, "treat buffer as an 1-byte stream"). Asking in element - # units then silently reads a byte range instead -- every block past the - # first came back as uninitialised memory. Convert through bytes. - blosc1_cbuffer_metainfo(src, &header_typesize, &header_flags) - if header_typesize <= 0: - raise ValueError("miniexpr: invalid chunk typesize") - getitem_start = ( nblock * block_nbytes) // header_typesize - getitem_nitems = block_nbytes // header_typesize # This is needed for thread safety, but adds a pretty low overhead (< 400ns on a modern CPU) # In the future, perhaps one can create a specific (serial) context just for # blosc2_getitem_ctx, but this is probably never going to be necessary. @@ -2855,10 +2872,10 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, # dctx = ndarr.sc.dctx if valid_nitems > blocknitems: raise ValueError("miniexpr: valid items exceed padded block size") - rc = blosc2_getitem_ctx(dctx, src, chunk_cbytes, getitem_start, getitem_nitems, - input_buffers[i], block_nbytes) + rc = getitem_span(dctx, src, chunk_cbytes, nblock * blocknitems, blocknitems, + input_typesize, input_buffers[i], block_nbytes) blosc2_free_ctx(dctx) - if rc < 0: + if rc != block_nbytes: raise ValueError("miniexpr: error decompressing the chunk") # For reduction operations, we need to track which block we're processing # The linear_block_index should be based on the same grid the output shares @@ -3012,13 +3029,17 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param while True: # block loop startA = nblockA * blocknitems[0] startB = nblockB * blocknitems[1] - rc = blosc2_getitem_ctx(dctx, src[0], chunk_cbytes[0], startA, blocknitems[0], - input_buffers[0], block_nbytes[0]) - if rc < 0: + # Element units, so via getitem_span() -- see its docstring. matmul + # only ever sees numeric scalars (typesize <= 8), so the capped-typesize + # case is unreachable here today; going through the helper keeps that + # from being a latent trap if it ever stops being true. + rc = getitem_span(dctx, src[0], chunk_cbytes[0], startA, blocknitems[0], + udata.inputs[0].sc.typesize, input_buffers[0], block_nbytes[0]) + if rc != block_nbytes[0]: raise ValueError("matmul: error decompressing the A chunk") - rc = blosc2_getitem_ctx(dctx, src[1], chunk_cbytes[1], startB, blocknitems[1], - input_buffers[1], block_nbytes[1]) - if rc < 0: + rc = getitem_span(dctx, src[1], chunk_cbytes[1], startB, blocknitems[1], + udata.inputs[1].sc.typesize, input_buffers[1], block_nbytes[1]) + if rc != block_nbytes[1]: raise ValueError("matmul: error decompressing the B chunk") batch = 0 while batch < batches: @@ -3862,10 +3883,6 @@ cdef class NDArray: cdef int rc cdef int32_t lazychunk_cbytes cdef c_bool owns_dctx = False - cdef size_t header_typesize - cdef int header_flags - cdef int64_t span_start - cdef int32_t span_nitems cdef int32_t want_nbytes lazychunk_cbytes = blosc2_schunk_get_lazychunk(self.array.sc, nchunk, &chunk, &needs_free) @@ -3897,26 +3914,16 @@ cdef class NDArray: if needs_free: free(chunk) raise RuntimeError("Could not create decompression context") - # blosc2_getitem_ctx() counts in the typesize the *chunk header* records, - # which is not always the array's: c-blosc2 caps a typesize above - # BLOSC_MAX_TYPESIZE (255) to 1 so its split machinery keeps working. - # Asking in element units then silently reads a byte range instead -- - # an index summary over a start * self.array.sc.typesize) // header_typesize - span_nitems = want_nbytes // header_typesize + # An index summary over a span_start, span_nitems, - view.buf, view.len) + want_nbytes = nitems * self.array.sc.typesize + rc = getitem_span(dctx, chunk, lazychunk_cbytes, start, nitems, + self.array.sc.typesize, view.buf, view.len) if owns_dctx: blosc2_free_ctx(dctx) PyBuffer_Release(&view) diff --git a/src/blosc2/schunk.py b/src/blosc2/schunk.py index b8d6fa879..e5efdfbe1 100644 --- a/src/blosc2/schunk.py +++ b/src/blosc2/schunk.py @@ -1179,8 +1179,46 @@ def get_slice(self, start: int = 0, stop: int | None = None, out: object = None) >>> f"Slice data: {slice_array[:10]} ..." # Print the first 10 elements Slice data: [200000 200001 200002 200003 200004 200005 200006 200007 200008 200009] ... """ + if self.typesize > 255: + return self._get_slice_wide_typesize(start, stop, out) return super().get_slice(start, stop, out) + def _get_slice_wide_typesize(self, start, stop, out): + """``get_slice()`` for a typesize above ``BLOSC_MAX_TYPESIZE`` (255). + + c-blosc2 records such a typesize as 1 in the chunk header so its split + machinery keeps working, but ``blosc2_schunk_get_slice_buffer()`` still + derives the ``getitem`` it performs for a partially covered chunk by + dividing byte offsets by ``schunk->typesize``. The two disagree, so the + read addresses the wrong range: usually it fails outright, and for a + single-element slice it returns the wrong bytes with no error at all. + An ``= stop: + return b"" + per_chunk = self.chunkshape + pieces = [] + for nchunk in range(start // per_chunk, (stop - 1) // per_chunk + 1): + base = nchunk * per_chunk + lo = max(start, base) - base + hi = min(stop, base + per_chunk) - base + chunk = self.decompress_chunk(nchunk) + pieces.append(chunk[lo * self.typesize : hi * self.typesize]) + data = b"".join(pieces) + if out is None: + return data + view = memoryview(out).cast("B") + if len(view) < len(data): + raise ValueError("Not enough space for writing the slice in out") + view[: len(data)] = data + return None + def __len__(self) -> int: """ Return the number of items in the SChunk. diff --git a/tests/test_schunk_get_slice.py b/tests/test_schunk_get_slice.py index 032105ebd..ae6dc23fc 100644 --- a/tests/test_schunk_get_slice.py +++ b/tests/test_schunk_get_slice.py @@ -116,3 +116,49 @@ def test_schunk_get_slice_raises(): assert schunk[start:stop] == b"" blosc2.remove_urlpath(kwargs["urlpath"]) + + +# --------------------------------------------------------------------------- +# Typesizes above BLOSC_MAX_TYPESIZE (255) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("typesize", [252, 256, 512]) +def test_get_slice_wide_typesize_matches_source(typesize): + """c-blosc2 records a typesize above 255 as 1 in the chunk header, but + blosc2_schunk_get_slice_buffer() still divides byte offsets by + schunk->typesize, so a partially covered chunk addressed the wrong range: + most slices failed outright and single-element ones returned the wrong + bytes with no error. An Date: Mon, 27 Jul 2026 08:31:16 +0200 Subject: [PATCH 16/86] docs: release note for the wide-typesize SChunk slice fix Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 642752a57..ad80d6e98 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -106,6 +106,16 @@ message when opening a nonexistent `CTable` in append mode. ### Bug fixes +- **`SChunk` slices were broken for typesizes above 255 bytes.** c-blosc2's + `blosc2_schunk_get_slice_buffer()` derives the `getitem` for a partially + covered chunk by dividing byte offsets by `schunk->typesize`, which the + chunk header contradicts once the typesize is capped. Across 153 slice + shapes at typesize 256, 150 raised `"Error while getting the slice"` and + the 3 single-element ones returned the wrong bytes with no error at all. + Reachable from ordinary data -- an ` Date: Mon, 27 Jul 2026 08:54:05 +0200 Subject: [PATCH 17/86] Link the SChunk wide-typesize workaround to Blosc/c-blosc2#796 So the workaround can be dropped when the upstream fix lands in the pinned c-blosc2, rather than outliving the bug it routes around. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 2 +- src/blosc2/schunk.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index ad80d6e98..647d7ea08 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -115,7 +115,7 @@ message when opening a nonexistent `CTable` in append mode. Reachable from ordinary data -- an ` Date: Mon, 27 Jul 2026 11:27:40 +0200 Subject: [PATCH 18/86] Drop the wide-typesize get_slice() workaround, fixed upstream Blosc/c-blosc2#796 is fixed: blosc2_schunk_get_slice_buffer() and the single-coordinate path of blosc2_schunk_get_sparse_buffer() now convert through the typesize chunks actually carry, so a partially covered chunk at typesize > BLOSC_MAX_TYPESIZE addresses the right range. Upstream also made partial getitem decodes return BLOSC2_ERROR_DATA rather than looking like success, which is how every downstream instance of this family stayed silent. SChunk.get_slice() goes back to a plain super() call. The three regression tests added with the workaround stay and now exercise the C path -- without the fix, 150 of 153 slice shapes at typesize 256 raise and 3 return wrong bytes, so their passing is what verifies the fix is live in the linked c-blosc2. getitem_span() in blosc2_ext.pyx stays: blosc2_getitem_ctx() still counts in the header typesize by design, so the miniexpr prefilter, the index sidecar reader and the matmul prefilter still need the element-to-byte conversion. The fix is post-3.2.3 and unreleased, so BLOSC2_MIN_VERSION (3.2.1) is now too low for USE_SYSTEM_BLOSC2 builds -- a system 3.2.3 compiles fine and silently reinstates the bug. Bump it to 3.2.4 once that ships. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 5 ++--- src/blosc2/schunk.py | 41 ----------------------------------------- 2 files changed, 2 insertions(+), 44 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 647d7ea08..7bc7d48dc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -113,9 +113,8 @@ message when opening a nonexistent `CTable` in append mode. shapes at typesize 256, 150 raised `"Error while getting the slice"` and the 3 single-element ones returned the wrong bytes with no error at all. Reachable from ordinary data -- an `>> f"Slice data: {slice_array[:10]} ..." # Print the first 10 elements Slice data: [200000 200001 200002 200003 200004 200005 200006 200007 200008 200009] ... """ - if self.typesize > 255: - return self._get_slice_wide_typesize(start, stop, out) return super().get_slice(start, stop, out) - def _get_slice_wide_typesize(self, start, stop, out): - """``get_slice()`` for a typesize above ``BLOSC_MAX_TYPESIZE`` (255). - - c-blosc2 records such a typesize as 1 in the chunk header so its split - machinery keeps working, but ``blosc2_schunk_get_slice_buffer()`` still - derives the ``getitem`` it performs for a partially covered chunk by - dividing byte offsets by ``schunk->typesize``. The two disagree, so the - read addresses the wrong range: usually it fails outright, and for a - single-element slice it returns the wrong bytes with no error at all. - An ``= stop: - return b"" - per_chunk = self.chunkshape - pieces = [] - for nchunk in range(start // per_chunk, (stop - 1) // per_chunk + 1): - base = nchunk * per_chunk - lo = max(start, base) - base - hi = min(stop, base + per_chunk) - base - chunk = self.decompress_chunk(nchunk) - pieces.append(chunk[lo * self.typesize : hi * self.typesize]) - data = b"".join(pieces) - if out is None: - return data - view = memoryview(out).cast("B") - if len(view) < len(data): - raise ValueError("Not enough space for writing the slice in out") - view[: len(data)] = data - return None - def __len__(self) -> int: """ Return the number of items in the SChunk. From 7cbd48b790169af049db76fb91170785dcfd7124 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 11:43:59 +0200 Subject: [PATCH 19/86] Read chunk sub-ranges via blosc2_getitem_bytes_ctx() blosc2_getitem_ctx() counts in the typesize the chunk records, which c-blosc2 caps to 1 above BLOSC_MAX_TYPESIZE, so its unit changes silently with the data. Upstream now offers blosc2_getitem_bytes_ctx(), which counts in bytes at any typesize (Blosc/c-blosc2 bc074b22, follow-up to #796). Every partial read here moves to it, and the cap rule goes back to living only inside c-blosc2. The three sites are the ones getitem_span() was written for: the miniexpr prefilter, where a --- src/blosc2/blosc2_ext.pyx | 81 ++++++++++++++------------------------- 1 file changed, 28 insertions(+), 53 deletions(-) diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index d47ccb214..64342571d 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -396,6 +396,10 @@ cdef extern from "blosc2.h": int32_t srcsize, int start, int nitems, void* dest, int32_t destsize) nogil + int blosc2_getitem_bytes_ctx(blosc2_context* context, const void* src, + int32_t srcsize, int32_t start, int32_t nbytes, + void* dest, int32_t destsize) nogil + ctypedef struct blosc2_storage: @@ -2630,38 +2634,6 @@ cdef int general_filler(blosc2_prefilter_params *params): return 0 -cdef inline int getitem_span(blosc2_context* dctx, const uint8_t* chunk, int32_t chunk_cbytes, - int64_t start_item, int32_t nitems, int32_t typesize, - void* dest, int32_t destsize) nogil: - """``blosc2_getitem_ctx()`` addressed in elements of *typesize*. - - ``blosc2_getitem_ctx()`` counts in the typesize the *chunk header* - records, which is not always the array's: c-blosc2 caps a typesize above - BLOSC_MAX_TYPESIZE (255) to 1 so its split machinery keeps working - ("treat buffer as an 1-byte stream", blosc2.c). Asking in element units - then silently reads a byte range instead -- results were wrong, partly - uninitialised, and no error was raised anywhere. Convert through bytes: - identical arithmetic whenever the typesize is not capped. - - Returns the number of bytes decoded, a negative blosc2 error code, or - -1 when the header typesize is unusable. Callers should treat a return - other than ``nitems * typesize`` as a failure; a short read leaves the - tail of *dest* untouched. - """ - cdef size_t header_typesize - cdef int header_flags - cdef int64_t span_start - cdef int32_t span_nitems - - blosc1_cbuffer_metainfo(chunk, &header_typesize, &header_flags) - if header_typesize == 0: - return -1 - span_start = (start_item * typesize) // header_typesize - span_nitems = (nitems * typesize) // header_typesize - return blosc2_getitem_ctx(dctx, chunk, chunk_cbytes, span_start, span_nitems, - dest, destsize) - - # Auxiliary function for miniexpr as a prefilter # Only meant for (input and output) arrays that are blosc2.NDArray objects. cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, @@ -2872,8 +2844,13 @@ cdef int aux_miniexpr(me_udata *udata, int64_t nchunk, int32_t nblock, # dctx = ndarr.sc.dctx if valid_nitems > blocknitems: raise ValueError("miniexpr: valid items exceed padded block size") - rc = getitem_span(dctx, src, chunk_cbytes, nblock * blocknitems, blocknitems, - input_typesize, input_buffers[i], block_nbytes) + # Ask in bytes: blosc2_getitem_ctx() counts in the typesize the *chunk* + # records, which c-blosc2 caps to 1 above BLOSC_MAX_TYPESIZE (255), so its + # unit changes silently with the data -- every block past the first once + # came back as uninitialised memory here. Bytes are unambiguous, and a + # block offset is already one. + rc = blosc2_getitem_bytes_ctx(dctx, src, chunk_cbytes, nblock * block_nbytes, + block_nbytes, input_buffers[i], block_nbytes) blosc2_free_ctx(dctx) if rc != block_nbytes: raise ValueError("miniexpr: error decompressing the chunk") @@ -2937,7 +2914,6 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param cdef int32_t chunk_nbytes[2] cdef int32_t chunk_cbytes[2] cdef int32_t block_nbytes[2] - cdef int blocknitems[2] cdef int startA, startB, expected_blocknitems cdef blosc2_context* dctx cdef int i, j, block_i, block_j, chunk_i, chunk_j, ncols, block_ncols, Bblock_ncols, Bncols, Ablock_ncols, Ancols @@ -3021,24 +2997,23 @@ cdef int aux_matmul(mm_udata *udata, int64_t nchunk, int32_t nblock, void *param input_buffers[i] = malloc(block_nbytes[i]) if input_buffers[i] == NULL: raise MemoryError("miniexpr: cannot allocate input block buffer") - blocknitems[i] = block_nbytes[i] // ndarr.sc.typesize first_run = False nblockA = block_startA nblockB = block_startB while True: # block loop - startA = nblockA * blocknitems[0] - startB = nblockB * blocknitems[1] - # Element units, so via getitem_span() -- see its docstring. matmul - # only ever sees numeric scalars (typesize <= 8), so the capped-typesize - # case is unreachable here today; going through the helper keeps that - # from being a latent trap if it ever stops being true. - rc = getitem_span(dctx, src[0], chunk_cbytes[0], startA, blocknitems[0], - udata.inputs[0].sc.typesize, input_buffers[0], block_nbytes[0]) + startA = nblockA * block_nbytes[0] + startB = nblockB * block_nbytes[1] + # In bytes, for the reason given in aux_miniexpr(). matmul only ever + # sees numeric scalars (typesize <= 8), so the capped-typesize case is + # unreachable here today; asking in the unambiguous unit keeps that from + # becoming a latent trap if it ever stops being true. + rc = blosc2_getitem_bytes_ctx(dctx, src[0], chunk_cbytes[0], startA, + block_nbytes[0], input_buffers[0], block_nbytes[0]) if rc != block_nbytes[0]: raise ValueError("matmul: error decompressing the A chunk") - rc = getitem_span(dctx, src[1], chunk_cbytes[1], startB, blocknitems[1], - udata.inputs[1].sc.typesize, input_buffers[1], block_nbytes[1]) + rc = blosc2_getitem_bytes_ctx(dctx, src[1], chunk_cbytes[1], startB, + block_nbytes[1], input_buffers[1], block_nbytes[1]) if rc != block_nbytes[1]: raise ValueError("matmul: error decompressing the B chunk") batch = 0 @@ -3914,16 +3889,16 @@ cdef class NDArray: if needs_free: free(chunk) raise RuntimeError("Could not create decompression context") - # An index summary over a Date: Mon, 27 Jul 2026 14:02:32 +0200 Subject: [PATCH 20/86] Bump internal C-Blosc2 and miniexpr deps --- CMakeLists.txt | 7 ++++--- doc/guides/optim_tips/tip_11_dsl_random.png | Bin 31615 -> 31826 bytes 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 1895b275a..6665640f4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,8 +15,9 @@ endif() project(python-blosc2) # blosc2_ext.pyx calls blosc2_schunk_lock()/unlock(), added in c-blosc2 3.2.x -set(BLOSC2_MIN_VERSION 3.2.1) -set(BLOSC2_BUNDLED_VERSION v3.2.3) +set(BLOSC2_MIN_VERSION 3.3.0) +set(BLOSC2_BUNDLED_VERSION v3.3.0) +# set(BLOSC2_BUNDLED_VERSION bc074b228968d6121b3c8c1a38c0afc0bbf923f6) if(WIN32 AND NOT CMAKE_C_COMPILER_ID STREQUAL "Clang") message(FATAL_ERROR "Windows builds require clang-cl. Set CC/CXX to clang-cl or configure CMake with -T ClangCL.") @@ -109,7 +110,7 @@ endif() FetchContent_Declare(miniexpr GIT_REPOSITORY https://github.com/Blosc/miniexpr.git - GIT_TAG 58d2d0b4a3aee3d1ac84b213712cf982744196c8 + GIT_TAG 08b232933cecc7a4c2acfc808c24d8d0604819da # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr ) FetchContent_MakeAvailable(miniexpr) diff --git a/doc/guides/optim_tips/tip_11_dsl_random.png b/doc/guides/optim_tips/tip_11_dsl_random.png index 5fa677c2663296363bd07b9a41c00bc9f6689512..43e864fd4d39aa61cd78c5d5641113274cf328ef 100644 GIT binary patch literal 31826 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

nd%kkmmQn|$*FIBpB7Bb5 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 literal 31615 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 D7@HUK From 9cb490acb5fa29ddd01eb534e225fe73ba272b9e Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 19:39:16 +0200 Subject: [PATCH 21/86] utf8 span driver: return a Utf8Array for string results The contagion rule from the design (a string-returning expression with a utf8 operand produces a utf8 result) had no implementation on the expression side: _utf8_span_eval() accumulated every result into one physical-length NumPy array, widening it as later spans returned wider --- src/blosc2/ctable.py | 31 ++++++++++++++++++++++------ tests/ctable/test_utf8.py | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 87f7dc5ce..2cd091158 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -12836,9 +12836,14 @@ def _lazyexpr_over_cols(self, expr: str, operands: dict, utf8_names: list[str]): def _utf8_span_eval(self, expr: str, operands: dict, utf8_names: list[str], *, strict: bool = False): """Evaluate *expr* in row spans, materializing utf8 operands per span. - Returns a NumPy array of the table's *physical* length (the coordinate + Returns a result of the table's *physical* length (the coordinate system every other predicate here uses); rows past the utf8 columns' - logical length keep the zero value of the result dtype. + logical length keep the zero value of the result dtype. A bool or + numeric result is a NumPy array; a **string** result is a + :class:`Utf8Array`, following the contagion rule -- a string-returning + expression with a utf8 operand stays variable-width rather than + widening every row to miniexpr's compile-time bound. It is built by + extending span by span, so only one span's `` out.dtype.itemsize: - out = out.astype(res.dtype) out[start:stop] = res + if utf8_out is not None: + # Rows past the utf8 columns' logical length: the string zero value. + utf8_out.extend([""] * (n_phys - len(utf8_out))) + utf8_out.flush() + return utf8_out if out is None: # empty table out = np.zeros(n_phys, dtype=np.bool_) return out diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index b1b9d6dd4..eedd331b2 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -1374,6 +1374,49 @@ def test_ctable_utf8_where_expression_splits_oversized_spans(): ] +def test_ctable_utf8_string_result_is_a_utf8_array(): + """utf8 is contagious: a string-returning expression stays variable-width. + + A `` Date: Mon, 27 Jul 2026 19:51:55 +0200 Subject: [PATCH 22/86] Bump miniexpr to 9e9b8d9 (varlen string output) Adds me_eval_varlen()/me_varlen_data_bound(). Nothing in blosc2 calls them yet, so this is a no-op for the built extension -- the static linker never pulls the new object in -- but the pin has to move before the blosc2 side can use it. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6665640f4..3ce0885c0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,7 +110,7 @@ endif() FetchContent_Declare(miniexpr GIT_REPOSITORY https://github.com/Blosc/miniexpr.git - GIT_TAG 08b232933cecc7a4c2acfc808c24d8d0604819da + GIT_TAG 9e9b8d9c7f2d23669dd1f298693282c3ccd3e5d7 # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr ) FetchContent_MakeAvailable(miniexpr) From f6b06438f0b1d6df6d1d1091ce8a7265d0e894da Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 21:57:35 +0200 Subject: [PATCH 23/86] Add compute_varlen(): Arrow varlen results from string expressions Wires miniexpr's me_eval_varlen() through to Python: - blosc2_ext.eval_varlen() compiles and evaluates into Arrow int64 offsets plus a UTF-8 blob, releasing the GIL around the evaluation; - Utf8Array.extend_encoded() appends offsets+bytes in bulk, with no decode to str on the way in (_rewrite_from's tail is now shared as _write_encoded); - blosc2.compute_varlen() runs a LazyExpr or a DSL-backed LazyUDF in row spans across a thread pool into a Utf8Array. Varlen output has no fixed per-element stride, so the prefilter cannot carry it and the spans are where the parallelism has to come from. This was built to close the Chicago Taxi benchmark's gap against DuckDB, and the measurement says the gap was never there. 1M rows, transform, one engine per process: fixed-width 0.81 MB stored, 133 ms varlen 34 B/row -> 1.14 MB stored, 149 ms The blob lands on DuckDB's 35.9 B/row exactly and still loses on both axes. blosc2 stores results compressed, and the fixed-width form's NUL padding compresses to nearly nothing while a dense UTF-8 blob has nothing left to squeeze. The 404 B/row that motivated this is the uncompressed result, which blosc2 never stored. On time, break-even is the ceiling: eval 290 ms + accum 70 ms serial, against a prefilter that runs in blosc2's C thread pool fused with compression. Kept as a representation feature, not a performance one -- it is the only route from an expression to an Arrow varlen result, which a Utf8Array-typed computed column will need. The docstring says so. In the benchmark it is opt-in via --engines "blosc2,blosc2 (varlen)"; running an engine second costs ~40% on this machine, which is why the two are compared one per process. Co-Authored-By: Claude Opus 5 --- bench/chicago-taxi/string-ops.py | 489 ++++++++++++++++++++++++++++++ src/blosc2/__init__.py | 2 + src/blosc2/_utf8_array.py | 33 +- src/blosc2/_varlen_expr.py | 152 ++++++++++ src/blosc2/blosc2_ext.pyx | 120 ++++++++ tests/ndarray/test_varlen_expr.py | 124 ++++++++ 6 files changed, 918 insertions(+), 2 deletions(-) create mode 100644 bench/chicago-taxi/string-ops.py create mode 100644 src/blosc2/_varlen_expr.py create mode 100644 tests/ndarray/test_varlen_expr.py diff --git a/bench/chicago-taxi/string-ops.py b/bench/chicago-taxi/string-ops.py new file mode 100644 index 000000000..cbc6a4c52 --- /dev/null +++ b/bench/chicago-taxi/string-ops.py @@ -0,0 +1,489 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +"""String workloads over the Chicago Taxi dataset: Blosc2 vs pandas/polars/DuckDB. + +The companion of `compare-query-methods.py`, for the *string* columns rather +than the numeric ones. Three tasks over `company` (` bool + transform 'co=' + company + '|pay=' + lower(payment_type) -> str + kernel the same, but branching on whether the company is a cab + company -- i.e. row-wise control flow, not one expression + +All three are timed; only `kernel` is plotted. It is the shape of the pandas-3 +blog kernel (datapythonista.me/blog/whats-new-in-pandas-3): every other engine +has to express it as a mask plus two fully-evaluated branches, whereas blosc2 +compiles it to a single masked pass with `@blosc2.dsl_kernel`. + +`blosc2 (raw)` is the same blosc2 path with `clevel=0` on operands *and* +result. Same container, same kernel, compression the only variable -- so the +gap between the two blosc2 bars is the price of compression, and the gap +between their footprints is what that price buys. + +Usage: + python string-ops.py # whole table, best of 3 + python string-ops.py --nrows 1000000 --apply + python string-ops.py --engines blosc2,numpy --nrows 1000000 +""" + +import argparse +import gc +import hashlib +import time + +import numpy as np + +import blosc2 + +PARQUET = "chicago-taxi-flat.parquet" +COLS = ["company", "payment.type"] + +# One chunk/block geometry for every blosc2 operand. Expressions combining two +# NDArrays only take the (miniexpr) fast path when the operands share a chunk +# grid, and asarray() picks the grid from the itemsize -- which differs between +# = nrows: + break + table = pa.Table.from_batches(batches).slice(0, nrows) + out = [] + for name in COLS: + col = table[name].combine_chunks().dictionary_decode() + out.append(col.to_numpy(zero_copy_only=False).astype(str)) + return out + + +# -------------------------------------------------------------------------- +# blosc2 +# -------------------------------------------------------------------------- + + +@blosc2.dsl_kernel +def taxi_label(company, ptype): + pay = ptype.lower() + c = company.lower() + if " cab" in c: + return "cab|" + c.removesuffix(" cab") + "|" + pay + return "other|" + c + "|" + pay + + +def blosc2_setup(co, pt, cparams=None): + cp = {"cparams": cparams} if cparams is not None else {} + kw = {"chunks": CHUNKS, "blocks": BLOCKS, **cp} + return blosc2.asarray(co, **kw), blosc2.asarray(pt, **kw), cp + + +def blosc2_filter(a, b, cp): + # strict_miniexpr: a silent fallback to the NumPy path would still give the + # right answer, so without this the number below would not mean what it says. + e = blosc2.startswith(a, "Taxi") & (b != "Cash") + return e.compute(strict_miniexpr=True, **cp) + + +def blosc2_transform(a, b, cp): + e = "co=" + a + "|pay=" + blosc2.lower(b) + return e.compute(strict_miniexpr=True, **cp) + + +def blosc2_kernel(a, b, cp): + return blosc2.lazyudf(taxi_label, (a, b)).compute(**cp) + + +def blosc2_raw_setup(co, pt): + return blosc2_setup(co, pt, cparams=RAW) + + +blosc2_raw_filter = blosc2_filter +blosc2_raw_transform = blosc2_transform +blosc2_raw_kernel = blosc2_kernel + + +# `blosc2 (varlen)` is the same expressions through blosc2.compute_varlen(), +# which evaluates via miniexpr's Arrow varlen entry point and returns a +# Utf8Array. It packs `transform` to 34.2 B/row against the fixed-width path's +# 264 -- right on DuckDB's 35.9 -- and STILL loses on both time and stored size, +# because blosc2 compresses its results and the fixed-width form's NUL padding +# compresses to almost nothing. Kept as the evidence for that; run it with +# `--engines "blosc2,blosc2 (varlen)"`, one engine per process, since running +# second costs ~40% on this machine. `filter` returns bool, so there is nothing +# to pack and it reuses the ordinary path. +blosc2_varlen_setup = blosc2_setup +blosc2_varlen_filter = blosc2_filter + + +def blosc2_varlen_transform(a, b, cp): + return blosc2.compute_varlen("co=" + a + "|pay=" + blosc2.lower(b)) + + +def blosc2_varlen_kernel(a, b, cp): + return blosc2.compute_varlen(blosc2.lazyudf(taxi_label, (a, b))) + + +# -------------------------------------------------------------------------- +# NumPy +# -------------------------------------------------------------------------- + + +def numpy_setup(co, pt): + return co, pt + + +def numpy_filter(co, pt): + return np.strings.startswith(co, "Taxi") & (pt != "Cash") + + +def numpy_transform(co, pt): + return np.strings.add(np.strings.add("co=" + co, "|pay="), np.strings.lower(pt)) + + +def numpy_kernel(co, pt): + c = np.strings.lower(co) + tail = np.strings.add("|", np.strings.lower(pt)) + # np.strings has no removesuffix(); endswith + slice is the same thing. + trimmed = np.where( + np.strings.endswith(c, " cab"), np.strings.slice(c, 0, np.strings.str_len(c) - 4), c + ) + cab = np.strings.add("cab|" + trimmed, tail) + other = np.strings.add("other|" + c, tail) + return np.where(np.strings.find(c, " cab") >= 0, cab, other) + + +# -------------------------------------------------------------------------- +# pandas +# -------------------------------------------------------------------------- + + +def pandas_setup(co, pt): + import pandas as pd + + return pd.Series(co, dtype="str"), pd.Series(pt, dtype="str") + + +def pandas_filter(co, pt): + return co.str.startswith("Taxi") & (pt != "Cash") + + +def pandas_transform(co, pt): + return "co=" + co + "|pay=" + pt.str.lower() + + +def pandas_kernel(co, pt): + c = co.str.lower() + tail = "|" + pt.str.lower() + cab = "cab|" + c.str.removesuffix(" cab") + tail + return ("other|" + c + tail).where(~c.str.contains(" cab", regex=False), cab) + + +def pandas_kernel_apply(co, pt): + """The row-wise spelling of `kernel`, which is how it would first be written. + + Off the scale next to everything else, and reported separately for that + reason -- it is the baseline `@blosc2.dsl_kernel` exists to replace. + """ + import pandas as pd + + df = pd.DataFrame({"company": co, "ptype": pt}) + + def f(row): + pay = row["ptype"].lower() + c = row["company"].lower() + if " cab" in c: + return "cab|" + c.removesuffix(" cab") + "|" + pay + return "other|" + c + "|" + pay + + return df.apply(f, axis=1) + + +# -------------------------------------------------------------------------- +# polars +# -------------------------------------------------------------------------- + + +def polars_setup(co, pt): + import polars as pl + + return pl.DataFrame({"company": co, "ptype": pt}), None + + +def _pl(df, e): + return df.select(e.alias("r")).to_series() + + +def polars_filter(df, _): + import polars as pl + + return _pl(df, pl.col("company").str.starts_with("Taxi") & (pl.col("ptype") != "Cash")) + + +def polars_transform(df, _): + import polars as pl + + return _pl(df, pl.lit("co=") + pl.col("company") + "|pay=" + pl.col("ptype").str.to_lowercase()) + + +def polars_kernel(df, _): + import polars as pl + + c = pl.col("company").str.to_lowercase() + tail = pl.lit("|") + pl.col("ptype").str.to_lowercase() + return _pl( + df, + pl.when(c.str.contains(" cab", literal=True)) + .then(pl.lit("cab|") + c.str.strip_suffix(" cab") + tail) + .otherwise(pl.lit("other|") + c + tail), + ) + + +# -------------------------------------------------------------------------- +# DuckDB +# -------------------------------------------------------------------------- + +# No removesuffix() in SQL; ends_with + a slice is the literal equivalent and +# stays away from the regex engine, which would measure something else. +_DUCK_NOSUFFIX = "CASE WHEN ends_with(c, ' cab') THEN c[1:length(c) - 4] ELSE c END" + + +def _duck(con, q): + # .arrow() yields a RecordBatchReader from duckdb 1.5 on, a Table before it. + res = con.sql(q).arrow() + if hasattr(res, "read_all"): + res = res.read_all() + return res["r"] + + +def duckdb_setup(co, pt): + import duckdb + import pyarrow as pa + + con = duckdb.connect() + con.register("t", pa.table({"company": co, "ptype": pt})) + return con, None + + +def duckdb_filter(con, _): + return _duck(con, "SELECT starts_with(company, 'Taxi') AND ptype <> 'Cash' AS r FROM t") + + +def duckdb_transform(con, _): + return _duck(con, "SELECT 'co=' || company || '|pay=' || lower(ptype) AS r FROM t") + + +def duckdb_kernel(con, _): + return _duck( + con, + f""" + SELECT CASE WHEN contains(c, ' cab') + THEN 'cab|' || ({_DUCK_NOSUFFIX}) || tail + ELSE 'other|' || c || tail END AS r + FROM (SELECT lower(company) AS c, '|' || lower(ptype) AS tail FROM t) + """, + ) + + +# -------------------------------------------------------------------------- +# driver +# -------------------------------------------------------------------------- + +WINDOW = 1 << 19 # rows per verification window; bounds peak memory of the check + + +def _window(x, lo, hi): + """`x[lo:hi]` as a NumPy array, for any of the engines' native containers.""" + if type(x).__module__.startswith("pyarrow"): # NDArray has a .slice() too + return x.slice(lo, hi - lo).to_numpy(zero_copy_only=False) + part = x[lo:hi] + return np.asarray(part.to_numpy() if hasattr(part, "to_numpy") else part) + + +def digest(x, n): + """Memory-bounded fingerprint of a result, for cross-engine agreement. + + A 24 M-row ` 2 else ("ms", 1000) + panel(axes[0], [v * scale for v in t], "kernel: time", f"{unit}, lower is better", "{:.2f} " + unit) + panel(axes[1], s, "kernel: result footprint", "MB held in memory", "{:,.0f} MB") + + fig.suptitle( + f"Chicago Taxi row-wise string kernel, {nrows:,} rows (Nx = vs blosc2)\n" + "'blosc2 (raw)' is the identical path at clevel=0: compression is the only variable", + fontsize=11, + ) + fig.savefig(path, dpi=130) + print(f"wrote {path}") + + +if __name__ == "__main__": + main() diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index dc02a0569..a04a052ad 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -568,6 +568,7 @@ def _raise(exc): from .batch_array import Batch, BatchArray from .list_array import ListArray from ._utf8_array import Utf8Array, utf8_array +from ._varlen_expr import compute_varlen from .objectarray import ObjectArray, objectarray_from_cframe from .ref import Ref from .b2objects import open_b2object @@ -930,6 +931,7 @@ def _raise(exc): "compress2", "compressor_list", "compute_chunks_blocks", + "compute_varlen", "concat", "conj", "contains", diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index 0ae4fac81..cd15fd02f 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -345,6 +345,10 @@ def _rewrite_from(self, pos: int, values: list[str]) -> None: encoded = [v.encode("utf-8") for v in values] data_arr = np.frombuffer(b"".join(encoded), dtype=np.uint8) lengths = np.fromiter((len(e) for e in encoded), dtype=np.int64, count=len(encoded)) + self._write_encoded(pos, data_arr, lengths) + + def _write_encoded(self, pos: int, data_arr: np.ndarray, lengths: np.ndarray) -> None: + """Replace persisted rows ``pos ..`` with already-encoded UTF-8 bytes.""" if pos == 0: start = 0 elif pos == self._persisted_rows: @@ -352,18 +356,43 @@ def _rewrite_from(self, pos: int, values: list[str]) -> None: else: start = int(self._offsets[pos]) new_used = start + len(data_arr) - new_rows = pos + len(values) + new_rows = pos + len(lengths) if int(self._data.shape[0]) != max(new_used, 1): self._data.resize((max(new_used, 1),)) if len(data_arr): self._data[start:new_used] = data_arr if int(self._offsets.shape[0]) != new_rows + 1: self._offsets.resize((new_rows + 1,)) - if values: + if len(lengths): self._offsets[pos + 1 : new_rows + 1] = start + np.cumsum(lengths) self._persisted_rows = new_rows self._bytes_used_cache = new_used + def extend_encoded(self, offsets: np.ndarray, data: np.ndarray) -> None: + """Append rows already encoded as Arrow offsets plus a UTF-8 byte blob. + + The bulk counterpart of :meth:`extend`: *offsets* has ``n + 1`` entries + indexing *data*, and nothing is decoded to ``str`` on the way in. A + producer that already speaks the Arrow layout -- miniexpr's varlen + output, an Arrow buffer -- hands its bytes straight through instead of + paying for ``n`` Python string objects. + + Parameters + ---------- + offsets: + ``int64`` array of ``n + 1`` byte offsets, starting at 0. + data: + ``uint8`` array holding ``offsets[-1]`` bytes of UTF-8. + """ + offsets = np.ascontiguousarray(offsets, dtype=np.int64) + if offsets.ndim != 1 or offsets.shape[0] < 1: + raise ValueError("offsets must be a 1-D array of n + 1 entries") + data = np.ascontiguousarray(data, dtype=np.uint8) + if offsets.shape[0] > 1 and int(offsets[-1]) != data.shape[0]: + raise ValueError(f"offsets[-1] is {int(offsets[-1])} but data holds {data.shape[0]} bytes") + self.flush() # keep row order: pending rows come before these + self._write_encoded(self._persisted_rows, data, np.diff(offsets)) + # ------------------------------------------------------------------ # Public write interface # ------------------------------------------------------------------ diff --git a/src/blosc2/_varlen_expr.py b/src/blosc2/_varlen_expr.py new file mode 100644 index 000000000..c1be0b671 --- /dev/null +++ b/src/blosc2/_varlen_expr.py @@ -0,0 +1,152 @@ +"""Varlen (Arrow) evaluation of string expressions into a :class:`Utf8Array`. + +A string-valued expression normally lands in a fixed-width `` 0.81 MB stored, 133 ms + varlen 34 B/row -> 1.14 MB stored, 149 ms + +The varlen blob hits 34.2 B/row, right on DuckDB's 35.9 -- and still loses, +because blosc2 stores results *compressed* and the fixed-width form's NUL +padding is almost free to compress, while a dense UTF-8 blob has nothing left +to squeeze. The 404 B/row figure that motivated this is the **uncompressed** +footprint; blosc2 never stored that. On time it cannot win either: the +prefilter runs in blosc2's own C thread pool fused with compression, whereas +varlen output has no fixed per-element stride, so the prefilter cannot carry it +and parallelism has to come from running row spans across a thread pool (the +Cython binding releases the GIL for that). + +Use it when you want the Arrow layout itself -- a :class:`Utf8Array` result, or +zero-copy handoff to Arrow consumers -- not to make an expression faster. + +``ponytail:`` the per-span accumulation resizes the backing arrays once per +span, ~25 % of the run (69 ms of 290 at 1 M rows). Preallocating from +``me_varlen_data_bound`` and trimming once would remove it; not done, because +break-even is the ceiling and that is not worth chasing. +""" + +from __future__ import annotations + +import re +from concurrent.futures import ThreadPoolExecutor + +import numpy as np + +import blosc2 +from blosc2 import blosc2_ext + +#: Rows per span. The scratch buffer a span needs is ``span * itemsize``, with +#: itemsize the compile-time bound, so this caps it at a few tens of MB for the +#: widths string expressions produce in practice. +_VARLEN_SPAN = 1 << 16 + +_DEF_PARAMS = re.compile(r"\s*def\s+\w+\s*\(([^)]*)\)") + + +def _dsl_param_names(dsl_source: str) -> list[str]: + """Parameter names of a DSL kernel, read from the source miniexpr compiles. + + Not from the Python function's signature: the AST rewrites in + ``dsl_kernel.py`` (notably the ``row["col"]`` one) can rename parameters, + and it is the rewritten source that miniexpr binds operands against. + """ + match = _DEF_PARAMS.match(dsl_source) + if match is None: + raise ValueError("Could not read parameter names from the DSL kernel source") + return [p.strip() for p in match.group(1).split(",") if p.strip()] + + +def _resolve(expr) -> tuple[str, dict]: + """Return ``(source, operands)`` for a LazyExpr or a DSL-backed LazyUDF.""" + if isinstance(expr, blosc2.LazyExpr): + return expr.expression, dict(expr.operands) + if isinstance(expr, blosc2.LazyUDF): + kernel = expr.func + if not isinstance(kernel, blosc2.DSLKernel) or kernel.dsl_source is None: + raise TypeError("Only LazyUDFs backed by a @blosc2.dsl_kernel can be evaluated varlen.") + names = _dsl_param_names(kernel.dsl_source) + if len(names) != len(expr.inputs): + raise ValueError(f"DSL kernel takes {len(names)} operands but {len(expr.inputs)} were given") + return kernel.dsl_source, dict(zip(names, expr.inputs, strict=True)) + raise TypeError(f"Expected a LazyExpr or a LazyUDF, got {type(expr).__name__!r}") + + +def compute_varlen(expr, *, span: int = _VARLEN_SPAN, max_workers: int | None = None): + """Evaluate a string-valued expression into a :class:`Utf8Array`. + + The result holds Arrow ``int64`` offsets plus a UTF-8 byte blob, so each + row costs its own encoded length rather than the compile-time width bound + that a ``>> import blosc2, numpy as np + >>> a = blosc2.asarray(np.array(["ab", "cde"], dtype=">> out = blosc2.compute_varlen("x=" + a) + >>> [str(v) for v in out] + ['x=ab', 'x=cde'] + """ + source, operands = _resolve(expr) + if not operands: + raise ValueError("A varlen expression needs at least one array operand") + + lengths = set() + for name, operand in operands.items(): + shape = getattr(operand, "shape", None) + if shape is None or len(shape) != 1: + raise ValueError(f"Operand {name!r} must be 1-D for varlen evaluation") + lengths.add(shape[0]) + if len(lengths) != 1: + raise ValueError("All operands must have the same length") + nrows = lengths.pop() + if nrows == 0: + return blosc2.Utf8Array(blosc2.utf8()) + + if max_workers is None: + max_workers = blosc2.nthreads + max_workers = max(1, int(max_workers)) + spans = [(a, min(a + span, nrows)) for a in range(0, nrows, span)] + + def run(bounds): + a, b = bounds + return blosc2_ext.eval_varlen(source, {k: np.asarray(v[a:b]) for k, v in operands.items()}) + + out = blosc2.Utf8Array(blosc2.utf8()) + with ThreadPoolExecutor(max_workers=max_workers) as pool: + # In waves of max_workers: ThreadPoolExecutor.map() submits everything + # at once, and each pending span holds its own scratch-sized result. + for i in range(0, len(spans), max_workers): + for offsets, data in pool.map(run, spans[i : i + max_workers]): + out.extend_encoded(offsets, data) + return out diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 64342571d..5e17dc1d2 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -748,6 +748,13 @@ cdef extern from "miniexpr.h": int me_nd_valid_nitems(const me_expr *expr, int64_t nchunk, int64_t nblock, int64_t *valid_nitems) nogil + size_t me_varlen_data_bound(const me_expr *expr, int block_nitems) nogil + + int me_eval_varlen(const me_expr *expr, const void **vars_block, int n_vars, + int block_nitems, int64_t *offsets, + void *data, size_t data_capacity, size_t *data_used, + const me_eval_params *params) nogil + me_dtype me_get_dtype(const me_expr *expr) nogil size_t me_get_itemsize(const me_expr *expr) nogil @@ -1027,6 +1034,119 @@ def me_output_dtype(expression, operands): free(variables) +def eval_varlen(expression, operands): + """Evaluate a string *expression* straight into the Arrow varlen layout. + + ``operands`` maps operand name -> a C-contiguous NumPy array; every operand + must have the same length. Returns ``(offsets, data)`` -- an ``int64`` + array of ``n + 1`` entries and the ``uint8`` byte blob they index, i.e. + Arrow ``large_string`` for `` malloc(sizeof(me_variable) * n) + var_ptrs = malloc(sizeof(void *) * n) + if variables == NULL or var_ptrs == NULL: + free(variables) + free(var_ptrs) + raise MemoryError() + + try: + for k, arr in values: + var = &variables[built] + operand_dtype = arr.dtype + var.dtype = _me_dtype_from_numpy_dtype(operand_dtype) + if var.dtype < 0: + raise ValueError(f"Operand {k!r} has an unsupported dtype {operand_dtype!r}") + var_name = k.encode("utf-8") if isinstance(k, str) else k + var.name = malloc(strlen(var_name) + 1) + strcpy(var.name, var_name) + var.address = np.PyArray_DATA(arr) + var.type = 0 + var.context = NULL + var.itemsize = operand_dtype.itemsize if operand_dtype.num in (18, 19) else 0 + var_ptrs[built] = var.address + built += 1 + + expression_bytes = ( + (expression).encode("utf-8") if isinstance(expression, str) else expression + ) + rc = me_compile(expression_bytes, variables, n, ME_AUTO, &error, &out_expr) + if rc != ME_COMPILE_SUCCESS or out_expr == NULL: + if out_expr != NULL: + me_free(out_expr) + out_expr = NULL + raise ValueError(f"miniexpr could not compile the expression: " + f"{_me_compile_error_details(rc, error)}") + + bound = me_varlen_data_bound(out_expr, nitems) + if bound == 0: + raise ValueError("The expression does not produce a string result") + + offsets = np.empty(nitems + 1, dtype=np.int64) + data = np.empty(bound, dtype=np.uint8) + offs_ptr = np.PyArray_DATA(offsets) + data_ptr = np.PyArray_DATA(data) + nvars = n + nrows = nitems + # Released so a caller can run spans across a thread pool: the span + # driver is the only source of parallelism here, since varlen output + # cannot go through the prefilter. + with nogil: + rc = me_eval_varlen(out_expr, var_ptrs, nvars, nrows, + offs_ptr, data_ptr, bound, &used, NULL) + if rc != 0: + raise ValueError(f"miniexpr varlen evaluation failed with code {rc}") + return offsets, data[:used] + finally: + if out_expr != NULL: + me_free(out_expr) + for i in range(built): + free(variables[i].name) + free(variables) + free(var_ptrs) + + cdef inline str _me_compile_status_name(int rc): if rc == ME_COMPILE_SUCCESS: return "ME_COMPILE_SUCCESS" diff --git a/tests/ndarray/test_varlen_expr.py b/tests/ndarray/test_varlen_expr.py new file mode 100644 index 000000000..9a3e8d58b --- /dev/null +++ b/tests/ndarray/test_varlen_expr.py @@ -0,0 +1,124 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +"""Tests for blosc2.compute_varlen(): Arrow varlen results from expressions.""" + +import numpy as np +import pytest + +import blosc2 + +VALUES = [ + "Flash Cab", + "", + "Sun Taxi", + "café", + "日本語のテキスト", + "emoji 🎉🚀", + "Yellow Cab", + "x" * 200, +] + + +@pytest.fixture +def operands(): + co = np.array(VALUES, dtype=" Date: Mon, 27 Jul 2026 22:02:03 +0200 Subject: [PATCH 24/86] Revert "Add compute_varlen(): Arrow varlen results from string expressions" This reverts commit f6b06438f0b1d6df6d1d1091ce8a7265d0e894da. --- bench/chicago-taxi/string-ops.py | 489 ------------------------------ src/blosc2/__init__.py | 2 - src/blosc2/_utf8_array.py | 33 +- src/blosc2/_varlen_expr.py | 152 ---------- src/blosc2/blosc2_ext.pyx | 120 -------- tests/ndarray/test_varlen_expr.py | 124 -------- 6 files changed, 2 insertions(+), 918 deletions(-) delete mode 100644 bench/chicago-taxi/string-ops.py delete mode 100644 src/blosc2/_varlen_expr.py delete mode 100644 tests/ndarray/test_varlen_expr.py diff --git a/bench/chicago-taxi/string-ops.py b/bench/chicago-taxi/string-ops.py deleted file mode 100644 index cbc6a4c52..000000000 --- a/bench/chicago-taxi/string-ops.py +++ /dev/null @@ -1,489 +0,0 @@ -####################################################################### -# Copyright (c) 2019-present, Blosc Development Team -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause -####################################################################### - -"""String workloads over the Chicago Taxi dataset: Blosc2 vs pandas/polars/DuckDB. - -The companion of `compare-query-methods.py`, for the *string* columns rather -than the numeric ones. Three tasks over `company` (` bool - transform 'co=' + company + '|pay=' + lower(payment_type) -> str - kernel the same, but branching on whether the company is a cab - company -- i.e. row-wise control flow, not one expression - -All three are timed; only `kernel` is plotted. It is the shape of the pandas-3 -blog kernel (datapythonista.me/blog/whats-new-in-pandas-3): every other engine -has to express it as a mask plus two fully-evaluated branches, whereas blosc2 -compiles it to a single masked pass with `@blosc2.dsl_kernel`. - -`blosc2 (raw)` is the same blosc2 path with `clevel=0` on operands *and* -result. Same container, same kernel, compression the only variable -- so the -gap between the two blosc2 bars is the price of compression, and the gap -between their footprints is what that price buys. - -Usage: - python string-ops.py # whole table, best of 3 - python string-ops.py --nrows 1000000 --apply - python string-ops.py --engines blosc2,numpy --nrows 1000000 -""" - -import argparse -import gc -import hashlib -import time - -import numpy as np - -import blosc2 - -PARQUET = "chicago-taxi-flat.parquet" -COLS = ["company", "payment.type"] - -# One chunk/block geometry for every blosc2 operand. Expressions combining two -# NDArrays only take the (miniexpr) fast path when the operands share a chunk -# grid, and asarray() picks the grid from the itemsize -- which differs between -# = nrows: - break - table = pa.Table.from_batches(batches).slice(0, nrows) - out = [] - for name in COLS: - col = table[name].combine_chunks().dictionary_decode() - out.append(col.to_numpy(zero_copy_only=False).astype(str)) - return out - - -# -------------------------------------------------------------------------- -# blosc2 -# -------------------------------------------------------------------------- - - -@blosc2.dsl_kernel -def taxi_label(company, ptype): - pay = ptype.lower() - c = company.lower() - if " cab" in c: - return "cab|" + c.removesuffix(" cab") + "|" + pay - return "other|" + c + "|" + pay - - -def blosc2_setup(co, pt, cparams=None): - cp = {"cparams": cparams} if cparams is not None else {} - kw = {"chunks": CHUNKS, "blocks": BLOCKS, **cp} - return blosc2.asarray(co, **kw), blosc2.asarray(pt, **kw), cp - - -def blosc2_filter(a, b, cp): - # strict_miniexpr: a silent fallback to the NumPy path would still give the - # right answer, so without this the number below would not mean what it says. - e = blosc2.startswith(a, "Taxi") & (b != "Cash") - return e.compute(strict_miniexpr=True, **cp) - - -def blosc2_transform(a, b, cp): - e = "co=" + a + "|pay=" + blosc2.lower(b) - return e.compute(strict_miniexpr=True, **cp) - - -def blosc2_kernel(a, b, cp): - return blosc2.lazyudf(taxi_label, (a, b)).compute(**cp) - - -def blosc2_raw_setup(co, pt): - return blosc2_setup(co, pt, cparams=RAW) - - -blosc2_raw_filter = blosc2_filter -blosc2_raw_transform = blosc2_transform -blosc2_raw_kernel = blosc2_kernel - - -# `blosc2 (varlen)` is the same expressions through blosc2.compute_varlen(), -# which evaluates via miniexpr's Arrow varlen entry point and returns a -# Utf8Array. It packs `transform` to 34.2 B/row against the fixed-width path's -# 264 -- right on DuckDB's 35.9 -- and STILL loses on both time and stored size, -# because blosc2 compresses its results and the fixed-width form's NUL padding -# compresses to almost nothing. Kept as the evidence for that; run it with -# `--engines "blosc2,blosc2 (varlen)"`, one engine per process, since running -# second costs ~40% on this machine. `filter` returns bool, so there is nothing -# to pack and it reuses the ordinary path. -blosc2_varlen_setup = blosc2_setup -blosc2_varlen_filter = blosc2_filter - - -def blosc2_varlen_transform(a, b, cp): - return blosc2.compute_varlen("co=" + a + "|pay=" + blosc2.lower(b)) - - -def blosc2_varlen_kernel(a, b, cp): - return blosc2.compute_varlen(blosc2.lazyudf(taxi_label, (a, b))) - - -# -------------------------------------------------------------------------- -# NumPy -# -------------------------------------------------------------------------- - - -def numpy_setup(co, pt): - return co, pt - - -def numpy_filter(co, pt): - return np.strings.startswith(co, "Taxi") & (pt != "Cash") - - -def numpy_transform(co, pt): - return np.strings.add(np.strings.add("co=" + co, "|pay="), np.strings.lower(pt)) - - -def numpy_kernel(co, pt): - c = np.strings.lower(co) - tail = np.strings.add("|", np.strings.lower(pt)) - # np.strings has no removesuffix(); endswith + slice is the same thing. - trimmed = np.where( - np.strings.endswith(c, " cab"), np.strings.slice(c, 0, np.strings.str_len(c) - 4), c - ) - cab = np.strings.add("cab|" + trimmed, tail) - other = np.strings.add("other|" + c, tail) - return np.where(np.strings.find(c, " cab") >= 0, cab, other) - - -# -------------------------------------------------------------------------- -# pandas -# -------------------------------------------------------------------------- - - -def pandas_setup(co, pt): - import pandas as pd - - return pd.Series(co, dtype="str"), pd.Series(pt, dtype="str") - - -def pandas_filter(co, pt): - return co.str.startswith("Taxi") & (pt != "Cash") - - -def pandas_transform(co, pt): - return "co=" + co + "|pay=" + pt.str.lower() - - -def pandas_kernel(co, pt): - c = co.str.lower() - tail = "|" + pt.str.lower() - cab = "cab|" + c.str.removesuffix(" cab") + tail - return ("other|" + c + tail).where(~c.str.contains(" cab", regex=False), cab) - - -def pandas_kernel_apply(co, pt): - """The row-wise spelling of `kernel`, which is how it would first be written. - - Off the scale next to everything else, and reported separately for that - reason -- it is the baseline `@blosc2.dsl_kernel` exists to replace. - """ - import pandas as pd - - df = pd.DataFrame({"company": co, "ptype": pt}) - - def f(row): - pay = row["ptype"].lower() - c = row["company"].lower() - if " cab" in c: - return "cab|" + c.removesuffix(" cab") + "|" + pay - return "other|" + c + "|" + pay - - return df.apply(f, axis=1) - - -# -------------------------------------------------------------------------- -# polars -# -------------------------------------------------------------------------- - - -def polars_setup(co, pt): - import polars as pl - - return pl.DataFrame({"company": co, "ptype": pt}), None - - -def _pl(df, e): - return df.select(e.alias("r")).to_series() - - -def polars_filter(df, _): - import polars as pl - - return _pl(df, pl.col("company").str.starts_with("Taxi") & (pl.col("ptype") != "Cash")) - - -def polars_transform(df, _): - import polars as pl - - return _pl(df, pl.lit("co=") + pl.col("company") + "|pay=" + pl.col("ptype").str.to_lowercase()) - - -def polars_kernel(df, _): - import polars as pl - - c = pl.col("company").str.to_lowercase() - tail = pl.lit("|") + pl.col("ptype").str.to_lowercase() - return _pl( - df, - pl.when(c.str.contains(" cab", literal=True)) - .then(pl.lit("cab|") + c.str.strip_suffix(" cab") + tail) - .otherwise(pl.lit("other|") + c + tail), - ) - - -# -------------------------------------------------------------------------- -# DuckDB -# -------------------------------------------------------------------------- - -# No removesuffix() in SQL; ends_with + a slice is the literal equivalent and -# stays away from the regex engine, which would measure something else. -_DUCK_NOSUFFIX = "CASE WHEN ends_with(c, ' cab') THEN c[1:length(c) - 4] ELSE c END" - - -def _duck(con, q): - # .arrow() yields a RecordBatchReader from duckdb 1.5 on, a Table before it. - res = con.sql(q).arrow() - if hasattr(res, "read_all"): - res = res.read_all() - return res["r"] - - -def duckdb_setup(co, pt): - import duckdb - import pyarrow as pa - - con = duckdb.connect() - con.register("t", pa.table({"company": co, "ptype": pt})) - return con, None - - -def duckdb_filter(con, _): - return _duck(con, "SELECT starts_with(company, 'Taxi') AND ptype <> 'Cash' AS r FROM t") - - -def duckdb_transform(con, _): - return _duck(con, "SELECT 'co=' || company || '|pay=' || lower(ptype) AS r FROM t") - - -def duckdb_kernel(con, _): - return _duck( - con, - f""" - SELECT CASE WHEN contains(c, ' cab') - THEN 'cab|' || ({_DUCK_NOSUFFIX}) || tail - ELSE 'other|' || c || tail END AS r - FROM (SELECT lower(company) AS c, '|' || lower(ptype) AS tail FROM t) - """, - ) - - -# -------------------------------------------------------------------------- -# driver -# -------------------------------------------------------------------------- - -WINDOW = 1 << 19 # rows per verification window; bounds peak memory of the check - - -def _window(x, lo, hi): - """`x[lo:hi]` as a NumPy array, for any of the engines' native containers.""" - if type(x).__module__.startswith("pyarrow"): # NDArray has a .slice() too - return x.slice(lo, hi - lo).to_numpy(zero_copy_only=False) - part = x[lo:hi] - return np.asarray(part.to_numpy() if hasattr(part, "to_numpy") else part) - - -def digest(x, n): - """Memory-bounded fingerprint of a result, for cross-engine agreement. - - A 24 M-row ` 2 else ("ms", 1000) - panel(axes[0], [v * scale for v in t], "kernel: time", f"{unit}, lower is better", "{:.2f} " + unit) - panel(axes[1], s, "kernel: result footprint", "MB held in memory", "{:,.0f} MB") - - fig.suptitle( - f"Chicago Taxi row-wise string kernel, {nrows:,} rows (Nx = vs blosc2)\n" - "'blosc2 (raw)' is the identical path at clevel=0: compression is the only variable", - fontsize=11, - ) - fig.savefig(path, dpi=130) - print(f"wrote {path}") - - -if __name__ == "__main__": - main() diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index a04a052ad..dc02a0569 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -568,7 +568,6 @@ def _raise(exc): from .batch_array import Batch, BatchArray from .list_array import ListArray from ._utf8_array import Utf8Array, utf8_array -from ._varlen_expr import compute_varlen from .objectarray import ObjectArray, objectarray_from_cframe from .ref import Ref from .b2objects import open_b2object @@ -931,7 +930,6 @@ def _raise(exc): "compress2", "compressor_list", "compute_chunks_blocks", - "compute_varlen", "concat", "conj", "contains", diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index cd15fd02f..0ae4fac81 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -345,10 +345,6 @@ def _rewrite_from(self, pos: int, values: list[str]) -> None: encoded = [v.encode("utf-8") for v in values] data_arr = np.frombuffer(b"".join(encoded), dtype=np.uint8) lengths = np.fromiter((len(e) for e in encoded), dtype=np.int64, count=len(encoded)) - self._write_encoded(pos, data_arr, lengths) - - def _write_encoded(self, pos: int, data_arr: np.ndarray, lengths: np.ndarray) -> None: - """Replace persisted rows ``pos ..`` with already-encoded UTF-8 bytes.""" if pos == 0: start = 0 elif pos == self._persisted_rows: @@ -356,43 +352,18 @@ def _write_encoded(self, pos: int, data_arr: np.ndarray, lengths: np.ndarray) -> else: start = int(self._offsets[pos]) new_used = start + len(data_arr) - new_rows = pos + len(lengths) + new_rows = pos + len(values) if int(self._data.shape[0]) != max(new_used, 1): self._data.resize((max(new_used, 1),)) if len(data_arr): self._data[start:new_used] = data_arr if int(self._offsets.shape[0]) != new_rows + 1: self._offsets.resize((new_rows + 1,)) - if len(lengths): + if values: self._offsets[pos + 1 : new_rows + 1] = start + np.cumsum(lengths) self._persisted_rows = new_rows self._bytes_used_cache = new_used - def extend_encoded(self, offsets: np.ndarray, data: np.ndarray) -> None: - """Append rows already encoded as Arrow offsets plus a UTF-8 byte blob. - - The bulk counterpart of :meth:`extend`: *offsets* has ``n + 1`` entries - indexing *data*, and nothing is decoded to ``str`` on the way in. A - producer that already speaks the Arrow layout -- miniexpr's varlen - output, an Arrow buffer -- hands its bytes straight through instead of - paying for ``n`` Python string objects. - - Parameters - ---------- - offsets: - ``int64`` array of ``n + 1`` byte offsets, starting at 0. - data: - ``uint8`` array holding ``offsets[-1]`` bytes of UTF-8. - """ - offsets = np.ascontiguousarray(offsets, dtype=np.int64) - if offsets.ndim != 1 or offsets.shape[0] < 1: - raise ValueError("offsets must be a 1-D array of n + 1 entries") - data = np.ascontiguousarray(data, dtype=np.uint8) - if offsets.shape[0] > 1 and int(offsets[-1]) != data.shape[0]: - raise ValueError(f"offsets[-1] is {int(offsets[-1])} but data holds {data.shape[0]} bytes") - self.flush() # keep row order: pending rows come before these - self._write_encoded(self._persisted_rows, data, np.diff(offsets)) - # ------------------------------------------------------------------ # Public write interface # ------------------------------------------------------------------ diff --git a/src/blosc2/_varlen_expr.py b/src/blosc2/_varlen_expr.py deleted file mode 100644 index c1be0b671..000000000 --- a/src/blosc2/_varlen_expr.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Varlen (Arrow) evaluation of string expressions into a :class:`Utf8Array`. - -A string-valued expression normally lands in a fixed-width `` 0.81 MB stored, 133 ms - varlen 34 B/row -> 1.14 MB stored, 149 ms - -The varlen blob hits 34.2 B/row, right on DuckDB's 35.9 -- and still loses, -because blosc2 stores results *compressed* and the fixed-width form's NUL -padding is almost free to compress, while a dense UTF-8 blob has nothing left -to squeeze. The 404 B/row figure that motivated this is the **uncompressed** -footprint; blosc2 never stored that. On time it cannot win either: the -prefilter runs in blosc2's own C thread pool fused with compression, whereas -varlen output has no fixed per-element stride, so the prefilter cannot carry it -and parallelism has to come from running row spans across a thread pool (the -Cython binding releases the GIL for that). - -Use it when you want the Arrow layout itself -- a :class:`Utf8Array` result, or -zero-copy handoff to Arrow consumers -- not to make an expression faster. - -``ponytail:`` the per-span accumulation resizes the backing arrays once per -span, ~25 % of the run (69 ms of 290 at 1 M rows). Preallocating from -``me_varlen_data_bound`` and trimming once would remove it; not done, because -break-even is the ceiling and that is not worth chasing. -""" - -from __future__ import annotations - -import re -from concurrent.futures import ThreadPoolExecutor - -import numpy as np - -import blosc2 -from blosc2 import blosc2_ext - -#: Rows per span. The scratch buffer a span needs is ``span * itemsize``, with -#: itemsize the compile-time bound, so this caps it at a few tens of MB for the -#: widths string expressions produce in practice. -_VARLEN_SPAN = 1 << 16 - -_DEF_PARAMS = re.compile(r"\s*def\s+\w+\s*\(([^)]*)\)") - - -def _dsl_param_names(dsl_source: str) -> list[str]: - """Parameter names of a DSL kernel, read from the source miniexpr compiles. - - Not from the Python function's signature: the AST rewrites in - ``dsl_kernel.py`` (notably the ``row["col"]`` one) can rename parameters, - and it is the rewritten source that miniexpr binds operands against. - """ - match = _DEF_PARAMS.match(dsl_source) - if match is None: - raise ValueError("Could not read parameter names from the DSL kernel source") - return [p.strip() for p in match.group(1).split(",") if p.strip()] - - -def _resolve(expr) -> tuple[str, dict]: - """Return ``(source, operands)`` for a LazyExpr or a DSL-backed LazyUDF.""" - if isinstance(expr, blosc2.LazyExpr): - return expr.expression, dict(expr.operands) - if isinstance(expr, blosc2.LazyUDF): - kernel = expr.func - if not isinstance(kernel, blosc2.DSLKernel) or kernel.dsl_source is None: - raise TypeError("Only LazyUDFs backed by a @blosc2.dsl_kernel can be evaluated varlen.") - names = _dsl_param_names(kernel.dsl_source) - if len(names) != len(expr.inputs): - raise ValueError(f"DSL kernel takes {len(names)} operands but {len(expr.inputs)} were given") - return kernel.dsl_source, dict(zip(names, expr.inputs, strict=True)) - raise TypeError(f"Expected a LazyExpr or a LazyUDF, got {type(expr).__name__!r}") - - -def compute_varlen(expr, *, span: int = _VARLEN_SPAN, max_workers: int | None = None): - """Evaluate a string-valued expression into a :class:`Utf8Array`. - - The result holds Arrow ``int64`` offsets plus a UTF-8 byte blob, so each - row costs its own encoded length rather than the compile-time width bound - that a ``>> import blosc2, numpy as np - >>> a = blosc2.asarray(np.array(["ab", "cde"], dtype=">> out = blosc2.compute_varlen("x=" + a) - >>> [str(v) for v in out] - ['x=ab', 'x=cde'] - """ - source, operands = _resolve(expr) - if not operands: - raise ValueError("A varlen expression needs at least one array operand") - - lengths = set() - for name, operand in operands.items(): - shape = getattr(operand, "shape", None) - if shape is None or len(shape) != 1: - raise ValueError(f"Operand {name!r} must be 1-D for varlen evaluation") - lengths.add(shape[0]) - if len(lengths) != 1: - raise ValueError("All operands must have the same length") - nrows = lengths.pop() - if nrows == 0: - return blosc2.Utf8Array(blosc2.utf8()) - - if max_workers is None: - max_workers = blosc2.nthreads - max_workers = max(1, int(max_workers)) - spans = [(a, min(a + span, nrows)) for a in range(0, nrows, span)] - - def run(bounds): - a, b = bounds - return blosc2_ext.eval_varlen(source, {k: np.asarray(v[a:b]) for k, v in operands.items()}) - - out = blosc2.Utf8Array(blosc2.utf8()) - with ThreadPoolExecutor(max_workers=max_workers) as pool: - # In waves of max_workers: ThreadPoolExecutor.map() submits everything - # at once, and each pending span holds its own scratch-sized result. - for i in range(0, len(spans), max_workers): - for offsets, data in pool.map(run, spans[i : i + max_workers]): - out.extend_encoded(offsets, data) - return out diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 5e17dc1d2..64342571d 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -748,13 +748,6 @@ cdef extern from "miniexpr.h": int me_nd_valid_nitems(const me_expr *expr, int64_t nchunk, int64_t nblock, int64_t *valid_nitems) nogil - size_t me_varlen_data_bound(const me_expr *expr, int block_nitems) nogil - - int me_eval_varlen(const me_expr *expr, const void **vars_block, int n_vars, - int block_nitems, int64_t *offsets, - void *data, size_t data_capacity, size_t *data_used, - const me_eval_params *params) nogil - me_dtype me_get_dtype(const me_expr *expr) nogil size_t me_get_itemsize(const me_expr *expr) nogil @@ -1034,119 +1027,6 @@ def me_output_dtype(expression, operands): free(variables) -def eval_varlen(expression, operands): - """Evaluate a string *expression* straight into the Arrow varlen layout. - - ``operands`` maps operand name -> a C-contiguous NumPy array; every operand - must have the same length. Returns ``(offsets, data)`` -- an ``int64`` - array of ``n + 1`` entries and the ``uint8`` byte blob they index, i.e. - Arrow ``large_string`` for `` malloc(sizeof(me_variable) * n) - var_ptrs = malloc(sizeof(void *) * n) - if variables == NULL or var_ptrs == NULL: - free(variables) - free(var_ptrs) - raise MemoryError() - - try: - for k, arr in values: - var = &variables[built] - operand_dtype = arr.dtype - var.dtype = _me_dtype_from_numpy_dtype(operand_dtype) - if var.dtype < 0: - raise ValueError(f"Operand {k!r} has an unsupported dtype {operand_dtype!r}") - var_name = k.encode("utf-8") if isinstance(k, str) else k - var.name = malloc(strlen(var_name) + 1) - strcpy(var.name, var_name) - var.address = np.PyArray_DATA(arr) - var.type = 0 - var.context = NULL - var.itemsize = operand_dtype.itemsize if operand_dtype.num in (18, 19) else 0 - var_ptrs[built] = var.address - built += 1 - - expression_bytes = ( - (expression).encode("utf-8") if isinstance(expression, str) else expression - ) - rc = me_compile(expression_bytes, variables, n, ME_AUTO, &error, &out_expr) - if rc != ME_COMPILE_SUCCESS or out_expr == NULL: - if out_expr != NULL: - me_free(out_expr) - out_expr = NULL - raise ValueError(f"miniexpr could not compile the expression: " - f"{_me_compile_error_details(rc, error)}") - - bound = me_varlen_data_bound(out_expr, nitems) - if bound == 0: - raise ValueError("The expression does not produce a string result") - - offsets = np.empty(nitems + 1, dtype=np.int64) - data = np.empty(bound, dtype=np.uint8) - offs_ptr = np.PyArray_DATA(offsets) - data_ptr = np.PyArray_DATA(data) - nvars = n - nrows = nitems - # Released so a caller can run spans across a thread pool: the span - # driver is the only source of parallelism here, since varlen output - # cannot go through the prefilter. - with nogil: - rc = me_eval_varlen(out_expr, var_ptrs, nvars, nrows, - offs_ptr, data_ptr, bound, &used, NULL) - if rc != 0: - raise ValueError(f"miniexpr varlen evaluation failed with code {rc}") - return offsets, data[:used] - finally: - if out_expr != NULL: - me_free(out_expr) - for i in range(built): - free(variables[i].name) - free(variables) - free(var_ptrs) - - cdef inline str _me_compile_status_name(int rc): if rc == ME_COMPILE_SUCCESS: return "ME_COMPILE_SUCCESS" diff --git a/tests/ndarray/test_varlen_expr.py b/tests/ndarray/test_varlen_expr.py deleted file mode 100644 index 9a3e8d58b..000000000 --- a/tests/ndarray/test_varlen_expr.py +++ /dev/null @@ -1,124 +0,0 @@ -####################################################################### -# Copyright (c) 2019-present, Blosc Development Team -# All rights reserved. -# -# SPDX-License-Identifier: BSD-3-Clause -####################################################################### - -"""Tests for blosc2.compute_varlen(): Arrow varlen results from expressions.""" - -import numpy as np -import pytest - -import blosc2 - -VALUES = [ - "Flash Cab", - "", - "Sun Taxi", - "café", - "日本語のテキスト", - "emoji 🎉🚀", - "Yellow Cab", - "x" * 200, -] - - -@pytest.fixture -def operands(): - co = np.array(VALUES, dtype=" Date: Mon, 27 Jul 2026 22:02:26 +0200 Subject: [PATCH 25/86] Restore the string benchmark dropped by the compute_varlen revert string-ops.py was untracked before the compute_varlen commit, so that commit was what first added it and the revert removed it wholesale. Back without the varlen engine, the Utf8Array footprint branch, or the StringDType digest branch -- none of which have a caller now. Co-Authored-By: Claude Opus 5 --- bench/chicago-taxi/string-ops.py | 462 +++++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 bench/chicago-taxi/string-ops.py diff --git a/bench/chicago-taxi/string-ops.py b/bench/chicago-taxi/string-ops.py new file mode 100644 index 000000000..e8a97a069 --- /dev/null +++ b/bench/chicago-taxi/string-ops.py @@ -0,0 +1,462 @@ +####################################################################### +# Copyright (c) 2019-present, Blosc Development Team +# All rights reserved. +# +# SPDX-License-Identifier: BSD-3-Clause +####################################################################### + +"""String workloads over the Chicago Taxi dataset: Blosc2 vs pandas/polars/DuckDB. + +The companion of `compare-query-methods.py`, for the *string* columns rather +than the numeric ones. Three tasks over `company` (` bool + transform 'co=' + company + '|pay=' + lower(payment_type) -> str + kernel the same, but branching on whether the company is a cab + company -- i.e. row-wise control flow, not one expression + +All three are timed; only `kernel` is plotted. It is the shape of the pandas-3 +blog kernel (datapythonista.me/blog/whats-new-in-pandas-3): every other engine +has to express it as a mask plus two fully-evaluated branches, whereas blosc2 +compiles it to a single masked pass with `@blosc2.dsl_kernel`. + +`blosc2 (raw)` is the same blosc2 path with `clevel=0` on operands *and* +result. Same container, same kernel, compression the only variable -- so the +gap between the two blosc2 bars is the price of compression, and the gap +between their footprints is what that price buys. + +Usage: + python string-ops.py # whole table, best of 3 + python string-ops.py --nrows 1000000 --apply + python string-ops.py --engines blosc2,numpy --nrows 1000000 +""" + +import argparse +import gc +import hashlib +import time + +import numpy as np + +import blosc2 + +PARQUET = "chicago-taxi-flat.parquet" +COLS = ["company", "payment.type"] + +# One chunk/block geometry for every blosc2 operand. Expressions combining two +# NDArrays only take the (miniexpr) fast path when the operands share a chunk +# grid, and asarray() picks the grid from the itemsize -- which differs between +# = nrows: + break + table = pa.Table.from_batches(batches).slice(0, nrows) + out = [] + for name in COLS: + col = table[name].combine_chunks().dictionary_decode() + out.append(col.to_numpy(zero_copy_only=False).astype(str)) + return out + + +# -------------------------------------------------------------------------- +# blosc2 +# -------------------------------------------------------------------------- + + +@blosc2.dsl_kernel +def taxi_label(company, ptype): + pay = ptype.lower() + c = company.lower() + if " cab" in c: + return "cab|" + c.removesuffix(" cab") + "|" + pay + return "other|" + c + "|" + pay + + +def blosc2_setup(co, pt, cparams=None): + cp = {"cparams": cparams} if cparams is not None else {} + kw = {"chunks": CHUNKS, "blocks": BLOCKS, **cp} + return blosc2.asarray(co, **kw), blosc2.asarray(pt, **kw), cp + + +def blosc2_filter(a, b, cp): + # strict_miniexpr: a silent fallback to the NumPy path would still give the + # right answer, so without this the number below would not mean what it says. + e = blosc2.startswith(a, "Taxi") & (b != "Cash") + return e.compute(strict_miniexpr=True, **cp) + + +def blosc2_transform(a, b, cp): + e = "co=" + a + "|pay=" + blosc2.lower(b) + return e.compute(strict_miniexpr=True, **cp) + + +def blosc2_kernel(a, b, cp): + return blosc2.lazyudf(taxi_label, (a, b)).compute(**cp) + + +def blosc2_raw_setup(co, pt): + return blosc2_setup(co, pt, cparams=RAW) + + +blosc2_raw_filter = blosc2_filter +blosc2_raw_transform = blosc2_transform +blosc2_raw_kernel = blosc2_kernel + + +# -------------------------------------------------------------------------- +# NumPy +# -------------------------------------------------------------------------- + + +def numpy_setup(co, pt): + return co, pt + + +def numpy_filter(co, pt): + return np.strings.startswith(co, "Taxi") & (pt != "Cash") + + +def numpy_transform(co, pt): + return np.strings.add(np.strings.add("co=" + co, "|pay="), np.strings.lower(pt)) + + +def numpy_kernel(co, pt): + c = np.strings.lower(co) + tail = np.strings.add("|", np.strings.lower(pt)) + # np.strings has no removesuffix(); endswith + slice is the same thing. + trimmed = np.where( + np.strings.endswith(c, " cab"), np.strings.slice(c, 0, np.strings.str_len(c) - 4), c + ) + cab = np.strings.add("cab|" + trimmed, tail) + other = np.strings.add("other|" + c, tail) + return np.where(np.strings.find(c, " cab") >= 0, cab, other) + + +# -------------------------------------------------------------------------- +# pandas +# -------------------------------------------------------------------------- + + +def pandas_setup(co, pt): + import pandas as pd + + return pd.Series(co, dtype="str"), pd.Series(pt, dtype="str") + + +def pandas_filter(co, pt): + return co.str.startswith("Taxi") & (pt != "Cash") + + +def pandas_transform(co, pt): + return "co=" + co + "|pay=" + pt.str.lower() + + +def pandas_kernel(co, pt): + c = co.str.lower() + tail = "|" + pt.str.lower() + cab = "cab|" + c.str.removesuffix(" cab") + tail + return ("other|" + c + tail).where(~c.str.contains(" cab", regex=False), cab) + + +def pandas_kernel_apply(co, pt): + """The row-wise spelling of `kernel`, which is how it would first be written. + + Off the scale next to everything else, and reported separately for that + reason -- it is the baseline `@blosc2.dsl_kernel` exists to replace. + """ + import pandas as pd + + df = pd.DataFrame({"company": co, "ptype": pt}) + + def f(row): + pay = row["ptype"].lower() + c = row["company"].lower() + if " cab" in c: + return "cab|" + c.removesuffix(" cab") + "|" + pay + return "other|" + c + "|" + pay + + return df.apply(f, axis=1) + + +# -------------------------------------------------------------------------- +# polars +# -------------------------------------------------------------------------- + + +def polars_setup(co, pt): + import polars as pl + + return pl.DataFrame({"company": co, "ptype": pt}), None + + +def _pl(df, e): + return df.select(e.alias("r")).to_series() + + +def polars_filter(df, _): + import polars as pl + + return _pl(df, pl.col("company").str.starts_with("Taxi") & (pl.col("ptype") != "Cash")) + + +def polars_transform(df, _): + import polars as pl + + return _pl(df, pl.lit("co=") + pl.col("company") + "|pay=" + pl.col("ptype").str.to_lowercase()) + + +def polars_kernel(df, _): + import polars as pl + + c = pl.col("company").str.to_lowercase() + tail = pl.lit("|") + pl.col("ptype").str.to_lowercase() + return _pl( + df, + pl.when(c.str.contains(" cab", literal=True)) + .then(pl.lit("cab|") + c.str.strip_suffix(" cab") + tail) + .otherwise(pl.lit("other|") + c + tail), + ) + + +# -------------------------------------------------------------------------- +# DuckDB +# -------------------------------------------------------------------------- + +# No removesuffix() in SQL; ends_with + a slice is the literal equivalent and +# stays away from the regex engine, which would measure something else. +_DUCK_NOSUFFIX = "CASE WHEN ends_with(c, ' cab') THEN c[1:length(c) - 4] ELSE c END" + + +def _duck(con, q): + # .arrow() yields a RecordBatchReader from duckdb 1.5 on, a Table before it. + res = con.sql(q).arrow() + if hasattr(res, "read_all"): + res = res.read_all() + return res["r"] + + +def duckdb_setup(co, pt): + import duckdb + import pyarrow as pa + + con = duckdb.connect() + con.register("t", pa.table({"company": co, "ptype": pt})) + return con, None + + +def duckdb_filter(con, _): + return _duck(con, "SELECT starts_with(company, 'Taxi') AND ptype <> 'Cash' AS r FROM t") + + +def duckdb_transform(con, _): + return _duck(con, "SELECT 'co=' || company || '|pay=' || lower(ptype) AS r FROM t") + + +def duckdb_kernel(con, _): + return _duck( + con, + f""" + SELECT CASE WHEN contains(c, ' cab') + THEN 'cab|' || ({_DUCK_NOSUFFIX}) || tail + ELSE 'other|' || c || tail END AS r + FROM (SELECT lower(company) AS c, '|' || lower(ptype) AS tail FROM t) + """, + ) + + +# -------------------------------------------------------------------------- +# driver +# -------------------------------------------------------------------------- + +WINDOW = 1 << 19 # rows per verification window; bounds peak memory of the check + + +def _window(x, lo, hi): + """`x[lo:hi]` as a NumPy array, for any of the engines' native containers.""" + if type(x).__module__.startswith("pyarrow"): # NDArray has a .slice() too + return x.slice(lo, hi - lo).to_numpy(zero_copy_only=False) + part = x[lo:hi] + return np.asarray(part.to_numpy() if hasattr(part, "to_numpy") else part) + + +def digest(x, n): + """Memory-bounded fingerprint of a result, for cross-engine agreement. + + A 24 M-row ` 2 else ("ms", 1000) + panel(axes[0], [v * scale for v in t], "kernel: time", f"{unit}, lower is better", "{:.2f} " + unit) + panel(axes[1], s, "kernel: result footprint", "MB held in memory", "{:,.0f} MB") + + fig.suptitle( + f"Chicago Taxi row-wise string kernel, {nrows:,} rows (Nx = vs blosc2)\n" + "'blosc2 (raw)' is the identical path at clevel=0: compression is the only variable", + fontsize=11, + ) + fig.savefig(path, dpi=130) + print(f"wrote {path}") + + +if __name__ == "__main__": + main() From 7ec1751d32bedf93bd4a58d78bf245d571eb688b Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 22:22:41 +0200 Subject: [PATCH 26/86] Bump miniexpr to 5a7de4f (width-preserving upper/lower) upper()/lower() no longer reserve a 3x/2x case-expansion bound on --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3ce0885c0..18650ab7a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,7 +110,7 @@ endif() FetchContent_Declare(miniexpr GIT_REPOSITORY https://github.com/Blosc/miniexpr.git - GIT_TAG 9e9b8d9c7f2d23669dd1f298693282c3ccd3e5d7 + GIT_TAG 5a7de4f5e493834970abdd2bce089c1d99f643d0 # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr ) FetchContent_MakeAvailable(miniexpr) From bbb94f4580b67a3e2f040ea09593aaac4b612f88 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Mon, 27 Jul 2026 22:43:14 +0200 Subject: [PATCH 27/86] Stop expression results from losing the code-unit shuffle width A 124.3 ms / 0.6 MB kernel 286.2 ms / 2.0 MB -> 232.1 ms / 0.6 MB Co-Authored-By: Claude Opus 5 --- src/blosc2/lazyexpr.py | 47 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index f3eadae20..9ac9c2217 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -231,6 +231,26 @@ def _get_result(expression, chunk_operands, ne_args, where=None, indices=None, _ _constructor_call_patterns = {name: re.compile(rf"\b{re.escape(name)}\s*\(") for name in constructors} +def _restore_code_unit_shuffle(cparams: blosc2.CParams, dtype) -> None: + """Give SHUFFLE the code-unit width that constructing a CParams erases. + + Left to itself, ``blosc2.uninit()`` shuffles a `` bool: return _constructor_call_patterns[constructor].search(expression) is not None @@ -1806,12 +1826,19 @@ def _miniexpr_eligible_operand(op): if use_miniexpr: cparams = kwargs.pop("cparams", None) - if cparams is None: + if cparams is None and getitem: # getitem output is throwaway scratch (returned as a NumPy array and # discarded), so compressing it buys nothing but a round trip. - cparams = blosc2.CParams(clevel=0) if getitem else blosc2.CParams() + cparams = blosc2.CParams(clevel=0) + # Otherwise leave cparams unset rather than passing CParams(): its + # filters_meta defaults to all zeros, which *overrides* the dtype-aware + # shuffle width uninit() would pick. For Date: Mon, 27 Jul 2026 22:47:56 +0200 Subject: [PATCH 28/86] bench: size the string benchmark's blocks for the result, not the operands The result container inherits the operands' block shape in *rows*, and a string result is much wider per row than its operands ( 15.6 ms transform 124.1 -> 79.7 ms kernel 231.6 -> 152.3 ms Co-Authored-By: Claude Opus 5 --- bench/chicago-taxi/string-ops.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bench/chicago-taxi/string-ops.py b/bench/chicago-taxi/string-ops.py index e8a97a069..9c0f6d54e 100644 --- a/bench/chicago-taxi/string-ops.py +++ b/bench/chicago-taxi/string-ops.py @@ -48,7 +48,15 @@ # NDArrays only take the (miniexpr) fast path when the operands share a chunk # grid, and asarray() picks the grid from the itemsize -- which differs between # Date: Mon, 27 Jul 2026 22:51:48 +0200 Subject: [PATCH 29/86] bench: refresh the string-ops results after the three fixes Co-Authored-By: Claude Opus 5 --- bench/chicago-taxi/README.md | 111 +++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/bench/chicago-taxi/README.md b/bench/chicago-taxi/README.md index ab941d71a..52b9bd05a 100644 --- a/bench/chicago-taxi/README.md +++ b/bench/chicago-taxi/README.md @@ -66,7 +66,118 @@ yourself if flushing manually): | `select-pandas-flat.py` | the query via pandas (parquet read + NumPy filter/sort) | | `select-polars-flat.py` | the query via polars lazy scan over parquet | | `select-blosc2.py` | the query via `blosc2.open()` + `CTable.where()` over `.b2z` | +| `string-ops.py` | a separate benchmark: *string* kernels over the same dataset (see below) | Each `select-*.py` prints the result, then `open:`/`compute:`/`print:`/`total:` timings; the driver parses the `total:` line (query time, excluding interpreter and import startup) alongside `/usr/bin/time`'s wall clock and peak memory. + +## String ops (`string-ops.py`) + +The numeric benchmark above is I/O-bound. `string-ops.py` is the opposite: it +loads the two *string* columns — `company` (` Date: Mon, 27 Jul 2026 23:41:38 +0200 Subject: [PATCH 30/86] bench: use LZ4-5 for the blosc2 string rows ZSTD-5 spends most of the blosc2 time on the string tasks for a ratio nothing here needs -- 68 MB against DuckDB's 842 either way. LZ4-5 is ~1.6x faster to write for ~3.6x more stored bytes, still 14x below the Arrow-backed engines. Full 24.3M-row table, against the ZSTD-5 numbers it replaces: filter 469 -> 297 ms transform 2.11 -> 1.22 s kernel 4.03 -> 3.09 s result 18 -> 68 MB That is fastest of all five engines on transform (1.62x DuckDB), ahead of DuckDB on filter, and within 1.04x on kernel. Compression also stops costing anything: the compressed run now beats the clevel=0 one on both transform and kernel, since a compressed block is less memory traffic than a 5.8 GB result. filters_meta has to be written out explicitly. It is SHUFFLE's element width, and a --- bench/chicago-taxi/README.md | 53 ++++++++++++++++++-------------- bench/chicago-taxi/string-ops.py | 23 ++++++++++++-- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/bench/chicago-taxi/README.md b/bench/chicago-taxi/README.md index 52b9bd05a..1a00d0eeb 100644 --- a/bench/chicago-taxi/README.md +++ b/bench/chicago-taxi/README.md @@ -99,31 +99,36 @@ row-wise control flow rather than one expression. blosc2 runs it as a fully-evaluated branches. `--apply` adds the row-wise pandas spelling, which is what you would write first and is ~70x slower than everything else. -**`blosc2 (raw)` is the same blosc2 path at `clevel=0`** — same container, same -kernel, operands and result both uncompressed. Compression is the only variable -between the two blosc2 bars, so their time gap is what compression costs and -their footprint gap is what it buys. +**blosc2 uses LZ4 at `clevel=5`**, not the ZSTD-5 default: on this workload it +is ~1.6x faster to write for ~3.6x more stored bytes, which is still 13x below +what the Arrow-backed engines hold. **`blosc2 (raw)` is the identical path at +`clevel=0`** — same container, same kernel, same filter pipeline, operands and +result both uncompressed. Compression is the only variable between the two +blosc2 bars. Results on an Apple M-series laptop (8 cores, 24 GB), full table, warm (see `string-ops.png`): | | filter | transform | kernel | kernel result | |---|---|---|---|---| -| blosc2 | 469 ms | 2.11 s | 4.03 s | **18 MB** | -| blosc2 (raw) | 174 ms | 1.29 s | 3.54 s | 5 766 MB | -| pandas | 192 ms | 1.99 s | 5.12 s | 932 MB | -| polars | 92 ms | 1.75 s | 3.88 s | 932 MB | -| duckdb | 334 ms | 1.99 s | 2.96 s | 842 MB | - -blosc2 is at parity with DuckDB on `transform`, 1.36x on `kernel`, and ahead of -pandas on both — holding the result in **46x less memory** than any of them. -`filter` is the weak task: a bool result is 1 byte per row, so there is no -output-side win to offset reading the operands. - -**Compression now costs ~12 % of kernel time and saves 315x the memory** — 18 MB -against 5.8 GB for the identical uncompressed run. - -Three things got this from an earlier 8.49 s `kernel`, and two were bugs rather +| **blosc2** | **297 ms** | **1.22 s** | 3.09 s | **68 MB** | +| blosc2 (raw) | 187 ms | 1.39 s | 3.58 s | 5 766 MB | +| pandas | 192 ms | 2.07 s | 5.28 s | 932 MB | +| polars | 93 ms | 1.75 s | 3.87 s | 932 MB | +| duckdb | 344 ms | 1.98 s | 2.96 s | 842 MB | + +blosc2 is **fastest of all five on `transform`** (1.62x DuckDB), ahead of DuckDB +on `filter`, and within 1.04x on `kernel` — while holding the result in **14x +less memory** than any of them. Only polars' `filter` is faster. + +**Compression is now free, and then some.** Compare the two blosc2 rows: the +compressed run is *faster* than the uncompressed one on both `transform` +(1.22 s vs 1.39) and `kernel` (3.09 s vs 3.58), because a compressed block is +less memory traffic than a 5.8 GB uncompressed result. It also stores 85x +smaller. `filter` is the one exception — a bool result is 1 byte per row, so +there is no output-side win to offset the operand reads. + +Four things got this from an earlier 8.49 s `kernel`, and two were bugs rather than tuning: 1. **`upper`/`lower` stopped reserving a 3x/2x case-expansion bound** (miniexpr @@ -139,10 +144,12 @@ than tuning: much wider per row, so a row count tuned for ` Date: Mon, 27 Jul 2026 23:45:21 +0200 Subject: [PATCH 31/86] bench: final string-ops plot (24.3M rows, LZ4-5) Co-Authored-By: Claude Opus 5 --- bench/chicago-taxi/string-ops.png | Bin 0 -> 68420 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 bench/chicago-taxi/string-ops.png diff --git a/bench/chicago-taxi/string-ops.png b/bench/chicago-taxi/string-ops.png new file mode 100644 index 0000000000000000000000000000000000000000..d57abc2e46fd5984abdc9a187000b86219e631a1 GIT binary patch literal 68420 zcmY(r2{hF28}~1wBB_v&rJZb%CHubbduS+6kNh#%Jbp-`DlN-tX5v@m3awoa_SZOiWCi#zuNJOiZji zOiauUN7=wL;y8{&;D<_}{;fb;|NDW#cOQE)ncoe3_`pB#fw#M8kmut7Z-2iV(z4g3 zl_f=?fq@SLRAgje|M!1L`#<)QxneNn1YYIXLnEgECMNE^gTIH4q$)pU0xxc?r)?LK zy)u2Q{naL8f6a1&Uj1O=V)3(H=k#8iLdj~V_R*A*r{5gJ#0%`ijGZR=tm74Z_0x^Q z&xZ$15cWgdX7|-Q-z(oB{K)>Q+!LxUl01KtJ{5pfr%%z<^B8Cpc2l{Ju(5p(T*1K~ zy}1?Wedhl?W@3K%|2=Tmd4A!451$}USsni0!;=c&-}{Y*O3eeja^UH^Nu%IJJ+Rvw zSWVl&>Az9Esp1}*FH9K)G(&O*zrK4C{#A>`_jr$!FILL(A|E@|-+P2r+c=&j&Wc$3 z$`_O?q!I`n#^Tz;l5)eZKu0RlRQLaFOxJnz*o50NiTWJoIaDmDxwlnBn(1h(v?+Pn zm!*X2%T%b8ur7S+iz|GVCF}8AHS%AG56T_=i`AlN_q)*{sY^fK3rFbQOW>0U+TGa< z7^!p&Cgg_K^rcH>Nw_pswb5t7r%|1I0wbl2Z9*QT=634?5udRdh*?(n?=SH+P9N^m zGFt-(L80q^9`Qi}Q%KWo&G`1vfG>o=rGbz3l@32KmhRKR$-?SBr~<-ro7kDWi1qlk z`6Sib3%zNOXlB;iN#~SrKV#>eA~(;jVs>fxn;Rl3f!{jzc1ShmS&B6_B_>TdRAE%h zZ+4}imGN5b7g8=wEyuX8w2)ApGt50ZX6(Gz{s<`e{FwWQ4%)AFG!P( zz5`S?PJwYku5!Tr=DVMthjjX4Q=jk^()(mOTdwAYe1Zi0RH(W4{Y_9LE;4Y)T&Yz* z;mk8cf9>7RQzB9S=DL$bu%&FT{q9+id6}L(-HOmqy7%oQecU5?VxKfzJ};p70Lo|Q z((DK4;FAudbnasyN2PDoisA$VhmqDbQTuzV?Hm27MVdUUcXU56!#{2;WVoSsHc9wr zHu)9ym8Ik8h`nusc+(2if>9uCx3SPikb9Xf<$`u@^l5ef`m%&pA!<sk!qJQ zUMc6{z5UNznk|p!yIV8dA}9JEv7cij;)>#SrEj;?s(XPID%-Kx70oj37PXs(+|1F8 z!noAmi?6gUdhR<|9M8&#WRR7>N}#kV2Q0Q^xQ2a?smlecVlt+RUE`Wj!Nyjudic~4 zk%(Wy5r4HgP7)(B)x(4K19;5rtaJ&CN)%>trfAf0gV<*CaTKg=6>CzSb<7{|0oMuM zxNkNP#2L8$$Cy5#%hj5!v9r{G5*e)d7<;5`;)55SGq%(`3mwDCfld@unp~^M18;Y9 zb7kVjk}{uLMBPuu)_iiC-!!^4TREU~a)(80ID+YQH>ZfkRN0VyxlKt#UxrNb##Xs) zS%g1kyACgHEACf+jqhPFSjM+6{R4ckVV-dm%STV~q91pT5}b)i#Q|Gv!q=*c=$M z_j21{{p8(D9znUyHo0xDFOcbY=C<_YxzBMfoJC-JJ1ZKB z!Nt+ri&+OE*59 zdAUeZ0^VlJ*Yt0D1Me$Ax^x=u(Y8|G_aM@g4nvH?&oeVkH5f~SY4<-V;3X_2~YY{Wc=>dPfrQ|PzZP41$ z&%&j4QXayulUd`Ja}0;62u*VmTmn0#VcD4g?yJ&LoiD0mv>r{P%!&|p*(RG{S)`$| zWSE!dvQHcFhW9K6F^+H~6&@G!G=k2KY}kbpJNNeja5jsn^(A(*#(1;7!617jEl=Zk znqzhBQt+MT(Bx*z%J9-8-|B?X@8QWQXfxl;+&14a>9N)TFV#J&TAJp7EMJm_e+TyQ zLhp9l>8aVrtH-Ta;tKly*$JE<`7%MS7-0`ypl6(5Fs|EF7qsBZ!jpGE{gAS zix8tOO8(*E;r`%#j$U@4=tGu-Be&*WG&b1@>r6aQaa(>jOi2<_HCWlnI(NY{*jIfs zceL7t5JZ^~qP5N<=sB<(o|{m?O9sWq>s|aiMrK)MQ!yMb`DBDQe3#FTc$In3FpO2x z(an)ZB~_&e2FrQElhAkpDn%Yt7*Lr%g(LrJ26Pe(Q>Cc4NOQ;FP^lXY_vgTeet!t8jqP{+rtu}% zt?K5P`d4FHa;5OwJ{?qk=q`8RE32O<$r&r3&r5X@sP@66?cqWSM|BSMXYcgbBX7|!dI}TA>FL$W(Pj&iqE_uINJ~R&D*-H zYAx{}i)!M09#o!3)x_0}3UG{VjrlHLaNzTFzKX!O^6CA*JETh`i>QeF^#+L zthUiGEb2Q;MGlS@6ywG$o^zwm+IGJK^_qQ=bUBac7`n>2u~%BUqsAA#H5@(|R_qUa zqinj&w7>sZgzI-u2Tk9?-tV+;I+cCV@1*hO=<>dfZz6Tl9<4P6n5Y{D{`1xxRyOU) zP12S5R*to1lrb0NsKDz*cHM@k?|L|veBFk(r#^&j`s~vm>kww4Skw5;5l1&5S(6I3 zkb)f;-#}@rfg9)9Ys+0og8uDyXXAun9ITCju|~;XcUmu9MU)nj&n7jsYUomKj1Wp~ zc+q!DXVG&dT6@Zb?D^dA)z)`J7gBfeq@@pbnc6N_Ui(x+K2}3pHKMm zIiz4Ax(7znHme|yDz zr4DrViH6ACorzicY*f@6vUjv-`=2f-S9Sammc+BNpM=AAmdm&!MCN;v zn?rjN1^EbdEJscphEJOz!)wRPt?+jV{W*_>lc^Du0Lpls$G{$P-nJ(Mh~wDBJPic; zr(3h%biQ<9IBjLkzUmfEcnGa`F)(L1vDzr*LXcY2_CkjCMiXctD@}tmltXnU+;bWY z_stHlSo{4I0oqac7jlVobz3lnW;vz-2V#NIufbT!UjVnf58ZpPXfUAGDqA?J!YaX{ zlhsG78Or&NN``Z(2!C3h_)rGpL!?JTL_EmrD>y>&#&|5329$6CRs@>SR^;yHSV$fp zEQ0`~P0wPMoaf6ZXLqz(I5l!}QiVoonLpvSI@O8*y0O7S-FXZWoKyXoL#F-Y$iGRE zMlRAhzSVH4!LYENyDek5;Ar8t-!CO%`a^g2flT&G_ICRV<i7k@e^`_f{2>RB$$f3;F>v&ZH_dKP6m|GCf30?kNIlXQ|>Qq{1@@XBFOU8%~x z43cVvMUILh1vE6?4Gedmr94%*VYl(8Wv&`O^C}@aOnO**{57y!5{vt|nNB>l70QYTSreZ}f5)R6KkC>3wdTlN{-i|;UoOdT?vhYueu+MLqPZ%Z(xK%3*9mVL4Q8xs zTV-I1{Kgy!1nytUgF#WoFzMWxh-2+cqxyA=9qYXk2%Nyphxt8tB8XPCyjnRc6#Yu zHJB#H7uxst7){w=lXoi<^i(=S@DdL@k8%>Dw&vpYyum>H%G?&x`OvxPfp?GguOPGU zPYU}R+5LRlBwgEY_Rq5ylinR|Z+$!bq_FvCdmeG5^6FW$SBm*aK_wMG9g^&o%lS(k zHT0r@GTH~|^0`U^1V~JbM^Dn_o1M&Z%~OvP)C~`%LqqdSM=I<;Ys`Q_@CBnH__JR0 zr-80vphSpyL7zF5wtCYf2w7m_PzOq1Zhu=fFz>|zSdZ+hKHGCN{J<0UUx_9o#}iAR z7nUyH8vfZ0Zd3QMz{~e%;}@?wqeHn@H_pjJtp+$hcRg{JWWb7dO<~ysqW6E%_7=1J zf4A>OE;L)4D|kel0}jYG*HE`qIej4WHZPsttyaV!dfk zopRO6=G1^PF$sRNliTQB@!lr&aH4P>(Oa5iYv;rI4~Zj+k%_@z6c;3rq_$g`OX-( zI3thYWO-{;Lt5sYdY;fm9!~Rq7V^hgjYYvl935}MTt@tl7fbu>oE!eC5g1M)ytUM* z+x`UGc-myxK#;A@2$p>6as)m*wDYX;>boi6INWW-4lme4sJfaQa8*0HwovTMwyg3} z%P^)qEK%V5Ek?A@`%t{5`0z7FPvKIt_rKpHXz*2|eEfYn?a4QV@B+&U=PO#vRjy$dw z9M;K#?G%6H%Km4BVdppjMOHmWm;2h0yCySIkbTW2smM#cpm9{U!K(7&nkiq_-=_Lu z!}h=?t5Pg&YBlsUplUPIT8Q`+2cynFb;wy;lZSRBNAw{jx^@nt z>AOZfWf~GfzSpoPB@jwTiAIwuzi~YD}*)ZBSZAnDh#uxI)DzCM{j| z@;rw*7DlS$ORZWQeXma4VJR4XGWs<=x2PLMytL4lffeYfxLYRhDw-uWL|an9>NAeo zrLEY$-Dv)4jR<>I+41hy!LF-rmfjm4A5{t6thdXP4$S9HW_~TOq7|!!d|txFX)K*G z6D5x)fYG&SJMhqXbjU8)CR1RF^MI6obJ_4M z7m&_Mur@0kjg@vKVDl&<|BOB|aat9rIM%(S@_!AJG*rpmT%KGsj{_l zVg$8pL@RwuVounJzZBS22>ow0`3u=GRsZEZ8%o1Ns zle^9G#@JfLovMCK^gG8K^wXp|Bc4mvi8n`_O@C-fAH#8;V7(T2!vL?Zpa5ST4i!Jg zNiA{qLq``qaRIYFcLb*pihY$hRqC9T!uH&FW!n+2cH7F9Q9)dOcA~ckp@+BKT;i$4 zncqO%53^(+C;dZeL0UHaO|mnXLm9?P`H;1oEV%0^i5T3H`MrReQ<0_WTB}mWxNkC$ z$j1pSA**-~UgEew47Y)uX65XlSmA;*{m|k^YXoInrHqAwcOu#kJt;i3{BMOEd~AgG zRbvhhIg-cJ<$7vAuHr+v7RJZaM-=mrWi_<@(9qx@FmF>%S{;5}T5er=oDE_H{Tn~@ zv+cE`){TPvchbXw+9(ce7c*53_%9qkSl8>TQx$kJ$$!kbihmU=_4?m*ZOT$|vl0JR z0w2|{W~)EezBK0hl@Doi@%BeOh@MaF$z9~lu*WZkPjXFZ2vo~vl8ZQ;JGjVh=y#m$ z&=C*xlEX=xYHPYe>ep$D?O1=fP66Vw>WGZ3*8wCWOqR|UxefBqD6Oy=> z6!&Y&(5$dSBLn&F?nitFwDhi_DXgUS6btp4$dYKA3!IoYgZR%bM_4r#ZtD09JSz1t zdjC$~_4$WSN(am?KyDhUpooPon(Tvys;3_n!wO*qOxZ~p4<~YQf93KTP;vO#O`$nN zlJUM2XTFes>StIzzz48vLpJHYm^g+7lv znnv8Iv_3O}Fd`M*sWp(6w691t6%P}FP)W=Z^7%-5AyZj>2=fgI>NfF{Gv5Mxd%0<9 z4B|K9N^i@6{_{PW97jtM@36t&k9FNrOkTe>mUD%Z&TQl+~=#MjNy%D(8fVc{v8&#d&o zkEmUTp4ym`yb=y(>tUhVPl{hOyd79;dF(S&WS3>hiRl55kiO_qSX z$+-PEe%B!#w#10{S07$*B2}y?qDVeB*>6^u7aiB{7&TX#zQ;($xD-uWe)jx+)ltiu zM{jt2bGO6~y(2vw9MZD-MHm$r7is6CmlsWI1v*hhvrPbzgp5VEAb8Q@PWddyP_Vp$ln9ztvDr+0#Rf0!$Wd z!_vQxG9AN-o>75#7xz;z4Cx0c+rhJlF?O_}>P@{CZT`~&3(8Z!&SWXV*7rMsXF>Z1 z+-5Oo?tvxYc2fNzLf-0$Qg6Vo$JEI7n7;*O-nrj|ktRZyZWf!)~?dV1Y~JK%xUU{vkPa#ByJ zu z5t^8fi1trADPNStOpCScJ_$7oTo;`Z!-k0%3oSTZtPkrci#)PAG!xjd|B!{F3eu(s z{qy%BNtjGyZ?0~b^$oO5q*mdOy51rTVkN3jg{{MS{$jO@7^OWw`pRhmab=BToV~wc zZ@Z$;%A-_w35|0~UKe@tGzG$mixJ6{sv1x@`k(-D3o zCy(md5#Y5H;GryY`FWDP6rrYV^`(zhx|Rwhgyq#!!PKuVhKZ>wIak{c{gQ7nbuQFK zQI$hjo;?*@(VGp3E^-N%|NA1h=uof4m>5>Y&6`Ofheh{@$6&s1W@iAQKYlW~J-}GL zNX?fi9pnv@{{((rnWAf7Dlti`54tN~d=(niJSmNTm z=Z|uSH*#^z;w)ZUf9L#ubww)G8+za$z&{K&DEN*ZRwsNsBGOQ!hn}zA#N{8U zP%rs$Xhh1^L9ZoR?6fZ4*-6>C37zZP3T%;NxUY>86t*$Yw$?jib0#@=ene-GX61Mt ze#*Wnb|%1NU}t$(bNkQR(GlakXt`6Vs73hT3ee~);6f@W2|j=WSR=d( ze?spMJ9A5TnQfvtB8X^F9+rFzjplN}RZ=8HAp;TO3-*|H{xw zXNHmZJc)P2(KaYKGOq9G;NBV2jDF~tDgHJg%F~nhud1d?B~IE6GbI-YKklP>;%CF? zFN2vTtI-r>R&@qv31SaqU*@#U&3;Z5OYeQV4n;WGo)fsXzuw7u#MJ-ZR39qPJ>rrp z_k{}KHkp=FFiJ2aICqE$z1_hKFgQz==jw?bC9BygTtDC6=$XlAHuDVejpbr^UU%d5ckJ?$;~*eI{{VcA#NPakmbuO%MI(L>C5zKzV7enof>JiCYx17m$LaYTgwC9_m4~(+Fa}tVscNPJY~-G;<87+?^q26 z496}88LK*c5)MDcs+?}jQkRFM0>oNx@Ti-nORhEgjP%xzw~xUpTQtXw_Pl8HD%PuMN^9M{JY)Pd>PcWblht(>Q#|I7+@ zJ<*^TZqsdCW|6ZZ>)93d{&m5Ja6pRm7Vw)9Fq&bWk+{cIm_qH9cG-#4-}nzts{q~-Y6F1QVaC5I8X=h%d#UG>CVfoo<0E~0UR>I$DCMiiTMcUAp; z+Ao!oblLL>u?_$X`i;{}qK!EvQSMy2%uF_ed%Fy&&z2Jxd!fv))lwJASAK$LTo4Xl zN=|)ALlkMpr%q%#h<&zoGF{`Oy-7#LS0BIPIVK*!wVY7jYF{LG-B!p*oVZXRX*?2q zyY|i@K>;oF>xR1`hnS2DYlet&{6so(eN|-*s@T_@95ORnz3sVH-c7fDat(w zj6JT;WTao=RwfW!kx{JT%znMXHO1i3C~Jp7xq194<>(tZ#L#O+=5V33%cah5E6S0R zVHL~ot;j}9Hy{0eI-+2wt}!CXW+Ru6w63@JJ;OA}0esBYFCD6#7YtsVopY$Vb@#nw zp|nG9ZWyrXAh~LxTMJyw=He?fYrcP-)Qk5yQXm*ER^uwP_lHUL54H;}Jg0~U=U(ZhMPGb$?h-`3iT zygF(5L7gINQUT(WaX_h?TJCqeST$QAR?iKwnRY-}Oh3Q{$6&ZhEpjp^D_OHoeY9oL z>VBi#=*Yxm(>vxC1-yD+FTSL)+X0COBoF?BS2E*QEY}pU;+FtYAVdcU0DcLD6VQxo zKL|NkNrPyhD)Fp62{M=^Py-1jY1h(<7hvPH9~XYSAM(63so8%Rn83AIN_IfMG1>l* z%}>04C)uxK(}CmC4B$P2VvUDZffwE!M0N&t_f3tQKtF)A&~_uhFKli2ZMH4^ak*&M zadrX-Q;!cUMnm7M4nOR^=G=e;F7Mb>6AV8E>gR$%vM}spBe;PR^&A-1jIS+MMvpYXzK!({s}Z1tQocbAByzY>u^eKIX3)C7h9}H+u(qC0<4BTM_)+ zjr*@ycbTUj_es&FK#Fz|8U`*4wgW~g9(f3<1lUrgKjdLW!1Pis#Y#`E76MWv`15nl zfCKKTW5 z@Ga%@f%)B(3_{9l-y5F}Z$cEVnWl=}B^5s3AF|XGqSOadW?=6Nfrf7&KsCoA>mN+i zznJc7bbED9dC(qd^^vym=CUa|P9*XsvJKC0kg4kV_2h{ z3_l1Z(vy4HLIS5~OSsQXMWfTyZLJ4c=Z%Ly*NnE@i`g1IyJ$p8V1Qp z4xEYCZ=s~z!mG`VS`s*L+n-gvo=PmAB!$0I4X#OJ9oWm?-pnb%?t!#0VHx4;HfH|w z_tyj$RkO~`;3e6UJFUyz)f$3AT$9a>t11?!3klx*$(nm1<2{WJMmjMdVg3#2|MC6? zOnC=$!jM$BYdG7N3-FDeCTmc*U^$zSn}}(!C2AS&Iz2YUa%a)!YZSW<2GwZC@*Z5MoQg0;FKT+rWE(8%*7#%+8LLnhUyt zElMT;#^-qE{sFHPDuZ6S5S|E@@Hn6vRb51OH3z^x`O}wjF^&St3T+xNPE9M)5cHPM zNX3GLZK;_ZC8|GLnHDDH-0&hSYmm=L zk`EF&MZ{pS@gqOBDfsBt^^l+ls&)oJ_zL$~eMxf|q` zazX!7jcLAmIBk5d4E~p!lm4(qUw@7&-s|yvqLP7{Fla8G z$q>ZmNYL#@|JfPGe62%n>5@(nz+KlK_&3>~Nl$n2TRQ*|7ZJark3{fZv#VVOmyjGa z1A66z;!t3<@2{X?k7$P1(pt^tdec6yfY+TsFW>x;quMM3Znbt^vxXp|X=ab7 z#SizO?N)zWK~L>D^|0Y!(BdXN>?`c*Hr_Nbfb6_8)S!k15@Q7(c<%X)J6%lS{b1Zo&5bMg!b zV}tgFwzYfWX@cfs!Hb}q!+P|WJSMq5cN~CmnG82Ory3*) zjiiJOqkVU&8V#I{C&Ga7>TSFu9AEO2J4u6s@?k`FA?F4&9rdL>4uG6^k7pcwO%(nd zQe%#Sd8cl(?Ij6^s=o^d)?FnG{jJJI0t9rQg@VZvs_? zQ43uk549w}wE{`ytAI#$!r!^iO(!OshgaD*4`{E)&$&ca^GY{Nz{Vtx4+J^57|jZ) zB=sv3&dma*m3OF)ryQmZLIuV@z}0%@wKb(o8fH3@V*$SQY`R9pYbjKPPK@|NR#l-O z&ADfT%vXbWpWNU$q|c~<$6hR2RZ5DHdMGmbELyshI<87-=N>^n^SuQW$9&Uqg(VglMe6o7t+T5k2k6ks zrwdg0{x(5`!(EJt0YuT)i7Oe9Ag6)7QjbMYxtcx2kG`?0{l+bxzr^}9FXVUpO)ReN zOp665|6bYiw!#RiwM#d^N>cuSoSPuL4IuB|l7@VC=#bH`Z>5hHO&hq@8W74rLp-cr znlNXC7e80M{~vifCeLaFj}{Rd43$4$mkU<$zIH6zL?GlH+bTAU%k{cFK{(m(?kk0+wl!WGaeT|Db&d8y5p;(86C~L7BdUxamYBVd~h{n4)-i}p_>UN=>1II?cCu$-FFsKACV50>mLhY684 zc7#WQeV07gSC^IX&Hl+7Gt{DA&Z4a0Ja&#ucG{T(ScB-dz>@6_rPQsM)ch1<&f=nHeN)8llrBW9f4}-jcqlkkqqB=#vwQ9D ztz%QAJDAZrVX*=a@j)dHVsv z`s;@dh-tdL%me)n%>EP6YS0v@a`M)7nY}sQcxd=dvH}<4?pFx`k%AOW~!k4)q=&NNN%_{?a zKk$f8g!Ny;InN_k47Fq@11uxDlZ1RX4(KY`BRpyR+$JC0Wx{34QzyzjSzYC?f~<$M zeVw`+?u1o|da8mV<4KI=mjEK8G>nc{nsb$83a)NdB@u^H#v>)q=WMg85Jb zHg3?X!c3KQk-Jc^XXavG9U&$lv_%#rIxQKwtYRJf#Nb zgJUCycY#Q8#0TvFRIz$7g?O#}ezL1B{Oo44&oXi^jYyvY-TAJYBmDIx%#KQY!AEau zX!*A_!BnMKH;V-Elx_kjlW&Qc3|ZzPPP-j zvyFC8N2SVR&t2Y(KxW2G%D`QZRqGQFi>E`{kth5BJh(DdObB5d$PqGt)n>_gbf66y z!(u8>yneH@06WiKFg$JAf&U|X_PleLNCm2tQuCC}cA#l&+k{{?GQdGs-v)Wdvt5k+ zbcfXp=~=j0Bk6(=mq5N*@uPt^QZ{HaDlMd;HqSiVQzHgvp5Q zt8OsmlACk+Od2H`&rwvJ;45ljGR}ZnIugBMD3MOjwX17Cf^b}J3kUN2ZbnH@p|Zm~ zer5)<3((wg3~w05lDO^I_ylg|jVHE~g!pE<0xNpXa!TpWuKA!?SNZ1p19E|`>+&h9 zn5msiP@#ujXpH~ud*gW#<#u%}5m{0F0nD=bJI_)u8^ke`h*zeMj1L}!mQCg@8(qs# z$3sT5z;Ak?oA~_UM?qfgjHfR>!jr={;?@O1e2#{K^AY6Skl)87EY!jApRCePIDvbA z%FA*egR@e80|D}PbgSg8V%atap0Kiamm5V&4&Pzty!G@YH5V>h(!B{%NG}%GC1zv1 z(;=RYtBKbIoX_roTG5yg#A+IdE9!d6_I^#^&$Wsg(PxhBZX0p)yf$MySFe&UP9}Yh z<7&2U;GN=uv?E`)uyI=#Z2vha{<@OPH~wyvHBMlP-R>zHjrkc{K+ysj&u(PM*d|@T z3l1A<)>YZI(8;XJEHVzKFOGT`v#@r6nJ6f_g5wVAZ#&c_PH;FGQSe@xS)& zG&_Y?5@*zl$vH=GoBnInWy5&M#yP}^1xE64 z%v1C!0Vz(=BCG*fu+e+CX(0cS&)Cwrvqnk@3RYQc+Jnk&N(t?X88K1LaSs90Y80p& z0W7;ShsQXLa(v~lCqN&j8qG?q2@#x?a`rE<^4OvwtfGjE`A0=hc-EKCoe8^pa1ID5 z-O}FEv8}sY^JwYu@LwCYgh+Y!I%zxj#|qUtpC#Ynk*@C2)lWV=UAGLj>=cUyEL}_8 zs(4-ue|;k`H76%?*`_ojMv(oA&!tg-c6J}r5X(Pna_E?Km(|6c^14OH&1Kodh|1%a zW%8GPikJlVc5H&6vC3N4S#wg6J$=dDlG9be%0r0rMtM8Jq{yc9%k*RaZJ<%O7$Gf) zneZfoZzJEnd+YzIOFc<$n~W1w@_TS`gDqDuQ0?j_v%D-pm>hs+8dbg^uj+_*CS^Ix z_agl?pn6`O!mPx>l7y&x8^tCo+XM435IXheX=)M);;KEWniBcQJB2YX(fKFTj)e7% zgZg*tuh_+Un>K>K~#+ zR>P%b88!{GjKW{>kqvpv;gg8OU;EvA+|)yM5P z%vZb;fT>|eLIBb?cf}$bdQn2gss7T^^Xi#Ax!?1u{hx&{&Uo=iCu{~bRx%a)$L}{W zJ?yrbH%d%1g7klam$Uy-uTlNt>>!3JIOnZQj43cv^9&GAutJfh*MR4nzPks2*1||l zAUx0q2OO*L83N?^V^>}ZJX#0IPtU*GaPgEgQqEsO%@T(n2FAJYX*zqsI26=6r1j7* z9Al$C#;`Ua8gknGv4)TGN~muN`FJU$A(CM>X$(6C=hZM`bnV^@V2 zt84JPrf^cgY*>F5%rS4tXN`VrUw@6>AnMpaxVU>UC+q;c&M__?V8}l}6d*yK9I0^a zK<6~`FquF_60YPU6KK#V`6yA!RJmD+)eLO|J$g00sVwFtQFw{f`~vQ4YfMI^BkDv} zdD;N|-eYUJ9X!}ccnE;vl;J{x8TjyG(^yJBcj3dE7GsuHJbj6W{D%KUK zVva1Myc9XzH#&IP!2RUp`i2Hqb}?iEq~(W$%=MGr`}U6IfBMm{Xj9zYIjQDy{xj|O zCJf?B;6smUf%$RseL_XNfb@`Kbn~s`=v&yhf7YZHwu`Z#Y9B+BpBkwG861Ny7xuQu2X#>HD7!Ex`N|*B|_Adus9L>}} zcj$aNJrdoi+MyCVC+En!Wr2L&v~)ZFNvq#AQmL-!ZndBWyH;FfM%L{>NfO(e$ja`9 zen+B>utbgrT*I2vpt>!lv!L-juby3ZxzE*Wv7<#4D)1C#Rxnv-XM%J4H>0)hAsoRp zyt}61*l~xs4dR;@wNwzd608H;8N$^tzmCK$@N1nTHWhG)@HS)Cx&_IFCY<7)x2fRZ zYK1{Q`a-#NIa|UiS|X&tPmLH>Wsr-eg-kG^V=Glhi1lTXS_ZaA@Cs8>NziyuE2m6Qcf>-}J{MF6(&j&Rs5mrvz?0e8G@rzn~ zG3b!7u;ncilVDSrVRft=El^For2x^_SH)?lpu#Ig8AIi%J8n_}*NXiLCb7I`IQ>_n zjuU)4*>-Uz1SUxF@>zQ&DCM1M4k7p8AckypKy-{gyQ}!k3avM^%Lt9{b&02SNflK} zIV|?v=6Q{BGVmW@5s=Rq5p-Z4tQNyL784j1OX7Zo=~-h*!e^u55=(7nILEM8&8}U^ z`M}i?0?NY}H3#_-7N}t(YVz@Vj>l(C8_~&Gg*$P~FDnGgV1=8bXt4J-?Pz``srQZ+ zZ{+!PL9Mu8609nltx!$evnGujWPCJrx;P+9BNp1`Qn{Gvr?Pymz;BdTN)jSXVm!KE ztp$H|*wtip98R<*91==kSM(OKzhgW!&-nK?w&|Hhc=ZQhz5DKDQ{P{EaoTj$Rgz&N zIm_62Am2G^iM8)C$D8(q0nnd(HX*rRP^G8b6v=jTXj_Ni?1>WZLYcYej9F&fnhB2x z)UeIcnGIDTEGtjCUEmx@Kbg}9&-M`+4KC}yIynRMjbZ9SDMG1WofSQopVhjvI|z=!(4Z`v<8&~9Iz_LdG;;MS8V z?{9c@hnhQq!#9+ERZ7El;|lP=8Q|CmAsl?^!tO>pFyNc~)^?XVx_<*7i@E?(vu6KU zO#bpge7C^KFL&v8z<iBLLpF$K8T&I0-f#W8o*X(jX@(CUBmg(~*eoy$DOup0&~Tr6 zY~Bcfx-ALDb8x= zA21_fj02P=0>pS!_03^h%xok>>h})uoq$Rp60v%4Gz*ard`%1pD|SjP^ZfAVGwBQD zu^k~O2p};a#2pC+PU_Zw$Hqc`<28Yy>51Qu;gGJSzmaj%aOsI=IkIy?NaatSKRC;V z`7aq*=^*roL`{vV2F{*@u>hv#-D%+d0hVy3WlZHD#|s97IOB};?RQ8R&$4pvLyD{h zIC_|UAbq9rL!EZam|=jQA8YWobpzqxZcoILT8dtP#w&_? zHpoqs;6%G{GZaOrPvuB5I4Y*WcvyobUURumTbUpSB>Vx<)`t2nbqE8#C}Im7boXE+ zYxw3!xQ5KJ&H{&ecf(*(Ge9?{ltxCc6Pkew1=dhBim0NbuDb#@P$q&B@(6w|=$4fulp4E*%?H#1VFY z_w@Du(DvT(ShxS*_!&`=CJGr>6QPn-vMQrtWo4XlRk9@+A+xKcj0z31vneu9yObg$ zd!1<6BMFu8dmj3H$9@04-}}BF_wVsL|M*;esB^r}_whPjuje`pH6)_cvlo}|^K&&> z;~I8{>@-IYfkl{Lkp9b{z`?mwFH*2PWdGB1JiA1a=l+W7VVhs3Ja0a z+zKgD{L_P1SzNlVXIwnpbEP)smubTc%~iet=Kj%H)7hihUcUx+oWevtT0B@tEuG3s zsLYbTQ&PIwQ^BL#eOT1P6b?bpBk#d?d57=mqIp#Q9(W%&>Y+UwCU4+*%6ehtjAvv$ zAoJ0t&Kl1d6q!Zi?@3;#GHzZ`>c?mpUd?^2_w5;L{kcW-&L(v`G#|A?p!1}jLCy~rvqUCtrb&Wch-oif zzl5Tw63vq=d?EV8#>`c(87MdSB-xh;sH7Mt-CkvWDXB!?=!su1af~(FGEyiq_ma=W zS`Vh^#|_P@KiFMv4stHXfR$2v5-p&>TIycC%kt?u6Lu7e&uvA7 zD8Y{8jVwL{tegIae3w5z{hwqje|~s_8}z}Shc*VrKYv=xY5DWV!vQ}3O=!%Y-;*a* z^#28ZqYEYE%e7U!?ke_!B+g{^80ahY=dJJ0+BmV znYA~_I(y{4kj{4Fc}QsK4+oAbZ1)L)W1Ui4uOfig5SPjkZGNq5ymwEXC-^LrV)v;o zT>mc6tR`4C^g%76zBzK#DyR4CMtw!%!xM*MUNWsn(HLT%Z%FVz+b_&172bzJ*(5;Y1ie^}fIc)Zs_ z!vIMgO{Q!D)R^kMKDrZVtqQ{JX>Vy}2fo)Is_ma_{|>gVkc7UVG#7#n%14c8=RM?{ zxBLnB4)+r4$RadZ8KB3-zCX4lQ*+BDTTMP8 zP+?iP1wMHOoP!zgLW#jfIhK-|;keTvV`ZTj=mp0uDz_boUS2Yd#CB9KKvJOdxtNjS zlYM2{@H5RF+meHHm&28vC!d)`h*dy@?2Jt#v$Dx04bc{Is-1hA;N7$smB^`qLWBYNL#slnxcoSc-1)N95pW?4-bFgZh6?*l0Y=2AMZT5+T;D^a*38n1e^=ZSi z)|$y|DbxD+#=G#OCPZG42?0Tq{p+EG4EwOunkQ0YFp;J`PIj>aCvI!X#8&7sYF%Cd zQtUmgb?Egt?r2d;XxFGn^8sCoPPI1}aue)uan`4%fowA*#Mm`LUG%GSge5v9-Q&a6 z4K&aRJq-5N-rX*pcGK=eicv4VY}F>q&aX(W%9&2N%k1>h9nFM2o~9+N)un-ERSfHS z|EUd{Fec&q;|wmE?y#}rZOSzWrw-?sl`}x1@>phEz4HUQ{M_913|Xi2J8QNb7??}p zO?PnyMQvAz6qFv`eU23^()&8TE7CqO`mkqPzTZsGokSN@F%PD@$8&>;gYPbAs7laP zEUl4+SUx;YGfKYX-VV)QiTwD-WdHUU!3UVpY!#OjwWy6KS)|NH(Ytl>0g#C&+Rbm8 z3Z&sd?>F6ssxjAyExnKILd33If(~z~s15ERvPaH}A($b05X&R)%Rk=|hUZj4V9!7M znN$0L^lsDnUhuWdVMV-jg;ZkUJLyUiVC(B?C0dan(i13EBhl)yI={5NN&R<}h@ZIJ z=c3w?iW29JdL=L#>zON`v(uv& z!g8>dqlv-9PT|8PyWTRFbMd+Ht$Zz?GH*P!+wb^U%>yAq=UOtRZ98*oS_~J{4HT%O z*6NLCe8;JE&EntNu!`fn7JQ?s7Y*pB12eYnh}gN(QQGYyi0+jdwAHC*T|VC>kTCrLK5YNPISl&U0wE8K>sCje9-Cu+X5 z@g9xi(!-ljQQv-UV%`8ThUmxPq1sH^j!RAknYa|w-gKpD!S$Mk9#s$E5^<>)Cp6`L z=b^K%CZwfZ+A|DZA^ldi_}qy{V_zX>ZlLL03ZW0uwb?w6yIxfue-$S?+L1dXl_8tw z+vIteD$~UK1thS>#0>Ui4gL;`6*}ikq3QW}JPIzmIejbMocjzylNww%gsa!9jcV;W zvq#IFd*x@)>AAU2+*b_3hpcb;wYPlUjiG--0DJmzjiAU>^CY^0^H9@+)V~mH2TLw|QprQOmrR{@-L|L3jv|QjI!}$4GgV1(mmJLTxqU;dj-qi3xlpgkeIWJh z293)EQBO)i!?%854{|0#75gnQ>>KYzdHoWqJ+p#o^o6sw81B;2@bhM_oZ5Dw^}mF= z_qz{Ai?vUzSZS6ZVYjzgedXr>=JZN-hn1f>TD!B%Z+Um*^>__yL>{p6r8|s!qDfwx zhZ{>R$}&oM#|dK{J5^`TNJ$(0w#uoKW}V3Zlru15w5b1pT{y3-74icsx=-URh#yY2 zeq6WhXg;f0zCi6K;et<0DRQRjheEfrJTbR4EdBlM)?y@*l-=c_mUNzwf7Tees|`nw z@(Avco^_s!?KPKZU9YGD^0v$>W|xzs;`dkl851#Pu}OPk9__u86tniIv*pK6&9_#3 zrmO3%5q09#J+qtGS0%6V%(Z)XPssH#Suyq$!sL2n*ElbQvXyW2`M_aoo0K^xq`_V4 z%<%p7eele~8$+RSx22m`=q5xl*kQ!IKziw8!$n44?&RKd)iKUldd4k~S-I(Q=2G_1 zwOoNS`#&(79Uq|m)nRi}ZSfu6x1c0mW!aXRdc%KL9&TbwiM+D1`z(B^5@zxIY>w-M zpUIv-acMX@qjVzuB_XwHCAvLYE!cZJydld&ARd}S@fc6e$7*)!IZ;h)`b(bt=+N|) zsPDxFZ@VS}#;-S3UoDrK<@ie0#J!Vc+WB(D%9bbk;TO<0|IOcOcFlKk+w{KoZ6%%_ zZG+vz{}`J(Ql%*Z(aU=!-rvtt$<^^Z*eu-i#)Vs{O(*%pwp6(|*)QCpqLN8oW@fGZ zjc7)Onq$E@e4_I1RKlHWda_#5sd{xr)OtB0 zWA-sL3tCr)E$+Ux-8<6cpsVXS+Mi^hEjZriZRUA#e1Rq6+KHLLns903`yb0j4yU^w z98Md3;wTqW)Oz#j>t7E$5|(M~5NBT!p-=W<4}el#kdIggBtftGvv8%;csHHdb`YYK zh?OeueS|Q&Soo)hv+P8l_?i}DS^q%G`q@7=%df`S^Q1hy2y0SOpFx~?b+z9a%j5aHT+c@1n>3e(BLFsRy zm9Ahecx`&sLn*sQ#zmJdG!3KUQJQ@mVfPM0W1D-bI{!u)of^BRoHS`ltaK@vcsOOI z2Eg+syCh0$_PX12|5(R{2%_?m5$W2uo4*_$)` zn-kOW(1HhAFSkFFy~VxPS|(+;(?KUyeM3K@(GI9jE7xsmR$kaerDeu9!H z6Nb9cGu&-oA;Qb-P0m>2^?O1!H8Z2^^nklfp=G@cgnyF9P9@M%JrA>M?>lr-G zeSMwQJrFn(f8?HR_6w3Rh_>2L%OGy8%Wz8#6#+bn=2Qx$Y)}pBdvpMd>)DzW}Vd1Y|j{)S~qla zN942KPlyKXHzo7I>YVXVv*_sPFfO&$<=k?H<2p(P=6i)5cs6S3-oI$Ks8*WZK;3x& z!m2S~1+K~4ayq}44LXJeohEsvXe@ZOR%|xEC|s7rr$ITf48!ZHfXVP=Pfxi?IVYF$cRs3RQz^LG3;iy&0&wav*om0sA9DcREa<=q}G zj(;0-gR?-4|F52yB#-|2{eSbuap(Mb{Qov4|9SdF%gCQU{(qUh)ud1>ouRJOuZY6; zzqFy@8CR3zk9?OeS-U zBy^J~$BA$iieH8MXz!%mzMXB2KeApLs`A!ne;-;XBI9=ex1=M4|5?$eA*QyAMB7|2 z+L>o8uvZHRD*eaACo0Bv(cpZHlRniP=79<)NK#A#0OdMJvKx7NhFc(7J zPD9LBA%roV-vQ9RCs2!DI0>;_n1Tp;*nN6r5M%lm2u5Eft^&xT*7b-9n1KHY=`&0d zM?(ZZz}dThA)BZYI6%P&L=+&TxrA*bSZGU+c3Kq;3I&O1_==6{ zpW=R*w<43{TnlAqOpz4^pv)cwc3prf{ZyOVbMxvxB1K&&>dul>54vhR4cA24_A{vI zpQz(xXT^TLOx?K9C4Elb>-Rt4IxqQ=_DY}_sP`o;xxWZ{)rkto)VnYOP-6~WHNjnWg^z&ibE`&DMkW&6s1dB} zg1wFqj=PvLaSc1SV07uKWvP@E?+p(@D+71N`B){e8jV%ZG2WZ<#M7UZaB{=7 zQOs&otDfwy%ZrZATKkeq(mI5AFsZ;Zs8~?c?jkqQfkgYa7!q@$qZ5IHjiJH6K&))& z#u8Ia3bIJ1Egb{#Fu=agh>89bW$?7M*uzyCX3EwSy`5x;Cit2((wv96>994(U%(Br z740ehb5`1}Ljne?w=!2UmkOUMDMHhgIowlPN;2>7*v$A7@3%-?75WAa#x!~XNf*#FVx_veR;Z=U>7;OCwHKwJNh z_m&1#MFJZ-6LtSieCX;(xk6%dpd(>%v;^q_O(vp$UWl^xtV{A|pbU;c-5sFVc7piV zi69qH%tRSKKNrx6&dfR;#e+P2vNvkk0w0it6Ow7oEsmXGMydbW?*!79v6ucD+Bt*L zl0WgO(s=g-itY40%+pOLv3?gHRt48S0(7t~Jr*kb1*vaiSPmbEe~=z&fg;77`meZf zf%J2N_Fy=?fK$9B=BnafL#f1Bxacq>A&#x%)0b?J||80Z1?kq!}p-Qj+4afQT&MfjNH9HT$aN@+N)s7+i1nBW&54cB#(|NVyb z-28uu^#6IT|9uPdFfNLN(ol-v!+Q9dL~6eq+ZVi&`~CIh+9+3)uNerh3sFw8x?It>KFbe%*bO(V_>Wb7;c`hwLS`{o!T$TJW@odJlC?3rs%*$mXjjKu}X zXHAokzmEsJpg1igZ2-ZB``Bhm_L^6Hh7s-~_JMKauYPpwujQSXqEM{XqY>e1AaED= zQGaF2Y49uQ8yWac2l>kJYy2p90=jR&qER~&Dw*#|fY z*IE2RvrlU66~CLnTOH94k#zJ(QU{R;9yLn{lhJhpAd{R)Bt_weYdj{XnLELX3qOK&5Qj;X97Y-(bca5|^rxO<&hU&|}Bnb@dV!(%*mpK{?P>==! zQpU0a7wki>J$(og+T7?tGRQM+gqLaGu=ZaB=B_`$GC-n5>F&en8Suc)4+8qSY3&&@ zk!0?TyEp9bfrrxPBU4F2Hg*WVabv?^()bY0XlkN`gAy#Sb!aUmHW(8BXtUU})nPTE z2#la{Ts?6vR($CgM(CtG0!veIA5gC_vzdS|Ouw_xVtjt$Czi2{{QQqI!}~AOom-hg zEe+95=KS@MwLbl1<_ns3Dv#4?s`uO!r`F0*_w>Q{C$^ul?AuBa@I-wVIMhJDY`!st zbrK%3kfhfmP|#S=o!NC)W}okoA8u$wN~w&}8#Ub?H7*7g6L|mui^UJ_n%qIs%H3^p zx#50L@Im6cmt|wML)X$q6p>SVFAGNW3~XrnjXwdL&i~Zit4)n*eub3q7D5TN3~vtO zd5CG;1!Q`zSZ$WqocpYC#s7zsy0%pRXO)0#e|45mp{V?0jli2{J0( zZ3_nn+VJl-PU*HFmDi`M&8Ee(S>nCkLD*FI6XL$BEb| zaV>-U=C&0V?!q(KhuDjBcHOYaDA=yi7n{WUKJWagg@sVAr0(@BeAbkWHHo)hjnNAW zv25rHQGOvT4l3)l*q!rQ1mA;OaRN%3Oio=Wn%wy;c_wx3lhB%^nRMrR88^KZl zr4_Fc?7&+(#!|#M_ube4DDTH(KqgkW+rE3SN6IKxd`-xOB!0<9&Bdw<23O=a1=J_K zQ(@u=jfuWiBqkC|-GP~5Psd+F4Zl&L3L&1TE^sDj?39l`mm(ZaE`*4AHP6dEsx@ z-+D4=G|qOpsF}H1geq&h4r+lXc7+1iedPLd$Box$gRD!@q_3I0xP+2?%V)FC$}iB4v^%!fSG)}Jo8c(UhrPmVF+D8eNutv3 z-v^#?^>F*#PHyyb2 ztl1VBYur&X4I%*_)9kBK;#Q&;Q(hdy-=<*8lTr+2xFO8C_6S5->Bjvipyo=-+CIWrDRgBT2fG{}ZAL9KwWwU!jSss6AVIgtlRwyE$_R6f*v=(mVm zFch1`;t+yd5TFoENG&*-Zy|!-c`zc=Y1Uan9@l4$oMA;QTZ|-tEMunu#i)I3v<%d@o%+RnOX_%LQMfaHXz?ayfXVmzzhA003@cZM} zR(+2yl{{_VTZA`vgO?hKA`)yXW#464D(gJ-Sy(rzm*9&)VfGBWUq;Vj^g4Ek*E*Vg z#XF7fml})QZ)Lt4uJHyBe2uD5^Hk|{EJ;3>nM5zq;>EAv^#Wh&fs`+J@^}j%(!BM7 zWKr9o+s{z+Wf+UO&X;vdC15EHC65?^5TkOe5RmKE{FT(~Zk%kemOly@sw`UJSfeQL zwlo6k!2eE;+mTP0VW3#Og5GN+PsN#>ZPKh30!e#5zPa{-8B7z?u2*VtBSn~>t&604 zc}!M_KZVA0(tYy!upe(NIkE<1_zU^m@v^`2JnH6?!Wg%{ey~HOpy=nf0>G!EVfz?8 zNE}f)kS>~FMka|M_zFVi4j(Fifdh25)jCmkL#rk|g!|!r`mGTF1@DPG%)IuU5Vzv5 zII&tsl#?G3(Yqk= zxOp!oiAUk*x6h7OSw06??^8^ZsG5Y!f*!H&e23RG-VjYYXm#FPrbph`SZ_Ha=T|SM zuZlajMQ1VG-F9(1VJq@y$H&)ZA86*E3an9+W{{tCD#0d_m1>leVvN&Kc0N!Ds*X~a z_AJt?4)1y=eJ-D)nzIm{(8LGEGA{pAJ#^Z5pXp=ziyBBs?h1&kXaORIp_Zqo_?sP_ zGO^)oSf<`67@soZ+-aBhs>qb4b&1$G6&cTaQUv6LaH`bYbnR2$qP0;oBkgB$Rvs!8 z_9(J4b?rL|6lw7z=Vvs6KRRv6tD+eu^6;h0!jii$K}=YUhTPq@;CjFlXoJh|+vsE; zJGU6lpBV)&a$&f2VIZUE+tKiZN~c4US=A}8zPn={aKiaY+5IF0oKvf5{_z7(lRB&J zqKC!TvD9AwzD!a4MIa;b2>68RR8}MBCdRu1n!CqojOzqDzoMgMWibQZ)<}{)=B-HE zPQr9}0M2+n$~uSM?h_6kiaTq}Pmy_S4X1wT%VweP323+4z!1^rOPvNAWG3Mte8<3p z9jRb0UD4mq=MkU1X#o=Je>Y~^GOg09-5flxpX^`e=KH-~V;|;~oV7(}u?G5z5CDC6X|h)v-{E=nvQq^yQ2VvBmNOP`gQ%sRg7aEhkIKQ4^l+)=i|Fcgf^# zqyHpKFzY8M6^&hGYZ8yY!K_wDf)PazQ3E6xtg`7DO9Vu<(+@N4Z(`F~vOGvUug7Ct zj%Sqkfu|wkgUf%A-%0tqw|XPn{W1|%=NNrA7IRIemdMAu|Mq))j1+8%r?=5@SkjA1 zF$jep9OqMe=;E((++g($Z0v?JXL)yYL;-diUj5NSKf`1Y6tj)AV6&Y!8jiQ$GC$Wo z?(7hQvM7hQy4z;(q=E74YK>{yNgFTo{S1`ahis6>{@04zah%1 zEEraRsg6}-h(Ym6$z?#M`U`J(w90fcPb<3))c;lJpmk*vrD1*ZQQbl2W-i_PHH0-3 ztV4sG)x$z@Cx(}3zcH3_3h16wPa?WB5J8B>lk^O7*3^Z*$rknDBj^#` z86n7-fb1n`w^ zN#p)?M`ewk{%sm1d8ZEcyqC3~%()M9$FNfxu3(L}YaV2`Y%18x&B7uqvyqMIB)eAA z>fr89p{k?0E@ozP2iaH>kDD~x2RfxCtH-#!#c5u5?dGzN(`EF6Pb|@3ntk~;Oq9ot ziz0JH8#$cU;@+=j9CqKf!Mrxfh7O%H{{=qt;iSJZUgyp8*CLQ@&UG3RfY<9X9)Cl3 zV!YvFedOw@vn!=3CyqAIih!)?$13Nrlj!Cfqg);06o)&r(wfPb`eG0gSHU0OCfW8qnjp!-Bc3j8uu|W3sfQYHT%srq+jlwy4eCKoH zI(URv2XB(-$gbLGsr3Zgvtd|#hr|bWy(GCq#>|vj6Tq5hWm2l2_$~OlHc5W_1{8Kg zmCcFGf8CEfRk)E`(zq-hR&D)($=3u?ASQk9E~Fkwx>o<@&K5e!*rf)sI~9EiGfrb| zM9|hp_A{+X0YZBHXzt>ly7+gjB&79}ef^uaixXLM>V5hfThzoFA_(HK`w7C>fgJTj z*wW^`bikbv7m*ZfoFdC1*gp5q@HyBt4O zYkeuzCDuRDgYZDacoHxHgTut%Z zeSd5t!~0#wHc5yg*ey9%p1cV>8XOceF;NqQEV+Qd+2qln!}bc&fPyQ_Rwqnb|M?k3 zZFtEma-Wc3yDx9=Rgz763VJ`>t-B-*(DjjrqS44VtRyzqYSFxWND2$*W@ic^6l_zE zka_P3JkcKC|B6+$SyqkP^DuTqTY+-Q>8T8gasb1s`F?7d&-dFGw>tE_c@+Aq5l{F= zV96_Vnak!p5ua2*=u^3GZ?=q)jESC!hx?sVfFho3tDNSQ`Rp>@9+fuCO0ig1d#CJS z+{rr!{~e%r)(X=intG-cfZD>%6afeYW*{{2f;f92(9v&S-hN@>coY7@hLtk7p|yG3}m&Kw&9zeQ@<>&31ed+FX30Y0ZYi-baar*gRE|wknj%K0|GhRNFBT>`N|DyPVRSFL%5Zn!xqA}PtT*zie`WEfT4WdS$ zQJ4lXgo3sB*V74zGPCo*QiHZ}oQy5i`q*RdrE10ac|aaWjgt2yytvMpoIkHliGStu zkrc3h2hZjMMnLv?(*QY+Wo_6&lHg08n}1- zbpQ_)u!RXZ$rQ8-flBE>@m`9#lDtC1RgnWgF9HR2C9^ugjOC+t)fPc+>qx8#Xxs1B zfI~ad3Z#v+JMwm9z^saBTD-uJ9*$ti)MpbR!rUC0*C4*kNAq3?D=2C13Q$}Nkua8z zo3;QEBES0OK=C1L>JA9eP$mut(rqWnCXO@b=xn_EB`n*azHtGV<%)JTTk#|Q&B%E< zVm=-ld0s$RykO#pz<)-N+CVsv4dVwGnO8c%N#qRZrV?QI5}AC4pN)}tqIW8A zhxpH;OBEGNLel^Wi)3yEKo3fKnowl6B@hL?{fmEAWB@E0g9LQC)D$-v6&L_H#?%BI z=#M76?7ExA&+#Rzt*sJ10Hsk|Wz*CTvR|P``nqhrGCdZklIaZKdt=J`Ne~zLXcdgs z?9qB^THJ+Jgbs*(uJK%LgcUt{>ZAY^?+{d|h?gA0WxaiUk+i-`C+ev3MH>OWDr5Z^ zYXNsc#s~7r4WSw2?Jq#?Sym4YN|;(>fKHd_fv46mHYaJn%-YC!CFoZvO=37x(LKZO zzV9Did9i%HDWl&VFC3R|MHn=(K5XrDu%hV$E$H@v-LFreDe00oxGw_-q@z zV&sh7`fMBs+cEyBQj_SVR11_TTRAFuDDjah8?j}v%(~j!|8{hP6j4M*+I7r? zRWL<18)!)q=!drR=l|cLgD4zN*>R&WhRdHybGq=aL0R2_>y71$)|JW)7mAiBc0j`l zjE9jhymr}NaBU08{SSe5`4F!>5O+@9k&c|y!hzUZcYp6EM=2v7)D=WfR_T=YivLQS zkn@iC@Di=shg+ZKRUX{Sk+ahIx`v>v_R%mAD}@g|ntjhuUgK8=N`vUx7(!O3)UzX? zRH#_z>7A3nL`3p05%8AyWQSI4zbekC7xWB=v`xQI4ZM=GbwJ9HA-|`&{}dfw7q#-=B#Y263RUv5uZ4MCu+lBMQwYa&e*T(iA*s%*~u0$x8K4gYLE)WOlt z9V@4ZWsvfx%u2rJD_@fHej$XaV)%#qiyIJzMGoe1 z$6n;Gk)YZ_luRUS+FUlbe#+n@VTQoE43Od4Y+8N`F~v83+&2EPfk*J_ zcUpo(Cvuj9FTuD9d!xSz=o2GYUR2`a@ZC~JxHam%wrv?O52;BS3)$bJfQ5q~4Y5{jAz$iY0Z z;GhHFZZs?CP(G7tOF(76I=v0m82t+{w-%r?G0IbSPS@Q~?cg%jWi{2QyK9w9WAbBH zOvcR4O6V#ncXbcFw>jH~Ati-r2_<-46Yt*OtYuTYlJGpRQicklx;4(D5$!cm!qFjc zlS;?UKSm52X|HxZz1=xF`_LjX(|4K+`M1k*d_`5%LpBWovfeeay zHQxy6?or}Yma^*zdE>e~!b|usfvPG3O3gKL=;|t(mO77+=J?-V5T|UgRm3jQ*X7?M zk5=rPg)lQS-&Ed6XI`Cx-Rs+2oH7NYxt+15QP|XtD48JQIlI2o&M*$Xg?H+N2M3YNyR z>ekVe`%-6mTLs5eLsAaOm#B;(%!1KkO^WVX0M`HLL*frjnC^T3W+^3}gCqv`;GFPe zqGl?@0qCQz4X|3b$Xh@kpud`Qxzl;#9L?!J0aQ2;)rGppbi0M<*Gt6S1fN5jg!4pG z(0ohp*$)f>RhGKDFW@$?2SGT7y5jX6fyJuDq*LN=qt)*m=tuF(wO`=QPD0VO zwZ|v_heJ>JT9$Ovo30$2`{c})^q6FO3)&n15H1F|5VdPitW%GYr_W(w zkAXpMVfzJ0A&=&7>hq20gcKHjKMxq&bhI&~pbZAeXVpXoPSjM*W=&-xEV?PI+2Vf{ z`=0U19)nmrxBM!@vVB!((ao3GERc0^nnf^WW1h;q4)ZnchgGk&?u#+8bDV6V+i=7# zqx3o%LHI{-XQq{LADST^9>k^Zk+f>4K-H>FAlOhAD#%$DN~(EVH}uJ4P*7&s|0b!Xcj_gnl^STVbUY+UOBc!D&pmNsDw$oP z>6HPn#hHC_aOuM-B_c)^yz9E@0FMeRwj)!)Zbhd_h=Gx5Y z8`FtKBi%SbmFreaj~)x(P@yfxGcg?Fx#+$`^ZoqzYb?)3{0nCh=PT)qLv5xOR1 z@%Vo4MVWDcoaanir3AGS`WMO?9YyV&A3l9c=Dv?$D$k9V5nKXdMEWixNC)xYnhvM4cpw?x_b%L#)AKw%8&LQLy1ES_c> z(YtljHJg0R@|zs)FZcgWYTwp<(SikzUmUS2vjKlBKDdmFsFBr}4zR)SPG_Q| zwtoVOAVw>Y1Y|n90MMfgy8snEy$L=t9bgW(4n3bl&7u#fBcLP1Bh)dRy?E2ja~0nK zOZp*#?LUZOjc2M1;&%n+mDO>RyTGK~-&cW_xe~8h5fbwQ%`y!r&^6ByJo^}yrvEr} zAzQ;qygW>vZ;Fx9GCc#aaUbU{(-9)xfG*iw;eCVa0|tNeUoP@dbt>6otBr$KXN}vhR;>!bAptZfTUCqG&}}cBqe8n~cyk&R;WfKf2(dGhG-uAQ zMZJEkr{RF_fUEKoInLy0CtgCpiW(_NxkVEnRxPgprS@ZC+Kc2~paYVJVj`~1l#SwZ z_yqiz6P|k{cNH(v6z?m>R+f zZAjWDoaQd|2Ynda7md>#y|zLby|@@a@2O94yl0{-Z=imLEN1n;G_Dow-s%F$q}eg` zo^xg`+1YjhJqC@ml|<&JZ!G#~2`_PXFLsl_Yx6JG?yFoZWJ^W}@4~bfWX={>NFQ0x zeMhH(bCd6^Uc+^bw%5jIIsN-lu~S|@pLhVi8(qL&=Ljb$Yn4w^&0eOB;|2YEGf3tu z0PM5~cUnZcZP###r|m}#?IhZLC6V9^!SypFJ3PE%XzWz9qOZ(k65(q$)V-QD zVG@va9kX2+hkKf7eV%Rm=_R-7Zxs`iY6?!^Dz@X;qp;R$u%xOP9w7a|%oGmad(ICD zCB>`B(ggo(g&dM0IZx^)uMWb!B=Z<%Ww=rfO#p?s^;{%9T%3=H(i!~K42XsVz0$d! zy)lfeR#E!KTC)UvTjQ)a0nKdA@ni=se_DRyr?+dhh`?lCjOKE$a9vdFFj!^em{&gq^Zv-&c=O9IN> z8-iPqA^i@F$2PZqP?((jG?+@Kyo^UCVIv~ znPKpzxR2gufo(z$V%MH8{>BKgtdR8Sisi%B={#0d2gVu`H6!|m`h29Hu?9ybP4|Ok zB5($5hf4e*wJ8naSNIUfdrLufrJ4r5$!`{##N`H;xJ0+7 zLuc+n^N~%UKMRpBP24VN*FuM~**5Mjlbd`e<8gVs+mo|*IaR+lqCmu_;C#>gv#E~h zF}h9Vb?dz?$rqof7k<9G5D-|M>H9sO-{jKY9IVlB-_@3QAAXpZ;yJ)HC@E0&`)AaT z=WHB;ckXQA&l}IdM)8_oM)$1`M*Kqb#T}EJyXIdLULnJ#)qbo=D9SDhdXVP8x6`At z*f?wR`02-akdA|sOt_RPQB@U;_W7;fp=%T9eo>}4>zw-KeH8j-ceJeOHr|f+Ntdn2 zj3y8Hu9aFj>zhR<8;&h(xS?jBQkQf*)q!5QP8|6(JnY*eJeRF1pnFX3c*)K1-tF?| zduTVeNS%B?(tf^;spD&<3RCmntl6$T3p7>i0q^KN&x4r8t3#iDzASc+M{a0Op3~1f zw7$hbw+(>#NRj=n`=P2NLl=oF3?^r9}*u4E? z`T(Y%V)upf|5(;w0PCRz&bVl}c5+x;JFUDfiYxWkO>VX0TS8Ca+Gd!Q`@w5HJBWcv z?OG0SYsG8HF-a?5gos0GGlTx5fFw+7yHgQSfr`!jXrGGv-LI#cuP+N;ouK&)I9iL=X-9!26 z?xY*#Izi~~RLnPEHCx>M5*KIvd zCJT7Nd)Ay+*A);YiP*m(45{Z^G+R%FU7>NJP@0VO4n*s z;4_lzL(4bH1kEg*xAKgQCd6$H4(`vOuE2?ch ziz9=GAmT6UF|tY>}l8zJ2dl0l;@H%BAAoF>zh{%F^rPk zNhT=E5e?TOD;Sr&lku>G%q`s%-M6QLv@4^s+~Cqb7oK2y$ow!JDkbgoAaO3eiyDT- z*r{`j0+zDhIkCv2*>`Q1&UWd^=;#fMIQr1n@br`Yfe4ejz;r9@*u+eC+Qh<84BPCs-2Irb)1N#6v6lghWOJ9Kpf>ctwFMvk*1E$QJnH+xv*#|5Zvwp#y$ zeG}VfA)o@|?JYni7rvahx18d^e0LTrd=d^hG~>h z=vfR!?X;k5b#*Q@N8i$VkLOqVW+yQr@!gd&nkdR_l`U*AD56JGYtOO1p0Cp zXuI;56I7lxm952W>4@c=ol(y{LAvC@M(A`*uy?+cN29dI75N5@B^vmJ+H7s$csY-K zemTK_tSgAKA!`|t?rqnMv{ittsAkYu2Y+#ED&ZMP0UJ33YBQ5Y{40v@B-tfdMo|~- z1FFCSx0$UT;=G5p@1ICuTs{)VM-Q;IJ-HLn+&#&>j_7UKUVp1tuNglrj7ujV$IPz0 zr9#TKZTFW}0xTq|<5JU!QXjH86C40z_sD)zYU(5o)G}|xAn5ae(I2KJS9s4ZlAgB{ zM_8B(O2Jks_qB{M131YFIo>zBnOAKZAP5zo`@mm2PcM-Em!M?W5^IKZVBz0UZVp*z zeKA~AVQYV*ZEas(hxRM?RuOKXSK0DLVMjRzexRqZUf0b9o8gmRZTQ$JkVJh{8I{kO zk`OqBu1d#a3U4`>kI|pyT9U>`IDbsg)}{vraz7#&5vWeixP_fSlkM}gVU5=&s0$7p zyyF3l-spLzwHtPs+E!kt1rF}u#+`Z*BGte}{}B#ZwBXvzz4QpR!4%E(13!P^V-AGo zCBg!P!yLQmb)Mq0_Y@Ghk!5%XXlgPo>LPL8=Viij5--&JAWN7D46b2M^06f)nJ$;P zCp2IHPpAQz?g~wd-NWAQB(7kP13}jNh>^qtb$|+LbFL9`u@CmN!R^F^Dt-1%@Pn3P zbsk8e5b1R*XWCHl-2^(6Ebj#`*UxY6$TZ?3MhFjMu=a71_ZLK8t5kj%0+?`;fzt^J-Ma(&S1JY;YBfA8` zS#r^GKJtF#Ke97*f{2-$egQN6Ef7nSC2==ZF6=w?MQZ=R9@k>I?_+VUm_13{h#qU- z3}6ABQF;ZeLqa+1srh;M(8xSBZ-o_AN=80&krJ4G#$a!gI+{p3nR>%ZpjopCW6wV) z+}J{g^Kq8ZD6=k}rJaz}YDS6^#9Fp~`*8={E}mMiLx;+(+>ws6-ixVsW#ZU7?iJK_ zA0Z{lHO)-w8$5gsZvi@Abmp?nXW6uhrJn!wZf{-1+dy3d3+Matupj|#Fz`>?0%+wC zL@VaSuEQ;eZzD9!;G6ajejB=jXa#~NV|^;{(QV6F=306b=Dx3|n^F2j9wgd!htTSE zJYN&m0sAzwT*C3YBo7QsdjSEsiL9CJwU&`I^dRKK9{F7q#WkVr1pe|rABwkTyn;7C zI9+5`tE%EW4&ZYxY^m85usgkc;RV@;i3QxV-)b)O@ze2xo~>6K)E#n8O+8L_o1%7r z)-8OCwd!;Boz}|MH|qmNHl?8RZi!|T&jJiu0AhFRtRrHAbB^MU*5Cmc1%AZ%zD4Nv zdc`9jF40@TNov=|A;2)fvEUXpYh%4J`Qb#KT|Q%SD6j-vsb0bmNH928Nqt04SZh+^nEs zTXP2Q^R}3V;smyFwa1c68ibnHlVeG<#4ch1yJ$bTB^Z#)7nmg`VR@HkwDZ`vg8sbbtwW!_SrGg~JIUN4o?M?bfxl>pq!|lNvDu)Gp4YO{~aTrsTPfo6bNu zXG}jeE*a{EgLPboIpY2^hde-U8f!|vXM?tbXP;wy=qDi{uIdZq*oMQJDAiqH!biR3 z*qjo^^_foK(Ce2P1ozssMEU7jW`X_tN$lg9D~XmNeeox>7JQfSJySB381Q#p%P|CG z*A-5mTh#>`tf?kXnF3We{keml3e4x0GaCWv3H_XeMk1mTc%pe} zu}^G?P3Y*QcJuq~`Hwk&XoFw8qHo+CV7j%@~VD_x%Rq*N(my(b<(W^EL6^~0E-6cGr zCAoD?O)6_b{RsB-BKvaMT4!)u+h&=yUl*4IeSfTn9Ksn?^VbhmAKqDKueIm=Bk|E! z!)$BpyGeo{L4OpU=Gh`Y>qA&EX!X>$8&9oqMpH#1-jo{u(@ZMMH!ej9zAV#C5tuq# z!|mF&oWWmFy#7tdkYAcWbk<)Wbda1lR=6}O@#BOq)8}UDzL(+}uxi!j2Y1q-cex>w&N5_1Gx!RuXcbM{IRP_#XP*T{%`aXO|iCgi{Vz;87 zpTb-5e|$bd{w_MJ@NYAq>SZyX&#xw!DanZ^@8oYI=bgS~*NT8>%{brT?|RFSWOb!l z!25>9LKMnDp(|f;_vdjJ(e{Lz=tgNt7w85al76vyq9m$mbYDo-^`*6oAppKTuD=#Q zpl`0_$$zH?w$L(8{rWjU7bH7;slHduAsj5--M?ka(Qe;gU%kn7utU1QRz`Ehf!O2p z%m$W#15`NYS!x+6o86Ivm)>+uoMYOnTAb*xOkLl)-{vNNX6?gY7tn6P#U zXH|U_@7^=M5P)y62vAHfuX|CG+Ddqr)KjG;-jBl4_rGu#ki^yBAjfyVL76 z{-p{J7AJ4yF%x?;+jfT#Lvypi+7S}7C|2_0V~DAOKs#7b1D5kOn`_O5K1#*^7_NxE zyk6Svr&xc4*9ncMHXC{QC=FYh+iM^_Vp`{o+>F^3>}|}q!-h7&rfFXM=mspiNeSLz7pAd+? zgn70QsIPIHrm5Q(j^ST_Sy!NitV2$Pz@1$%@%S{c+ee4Q6IjVy{@RIP?VM{*k!E3| zX^Mk3NiwCBRy*7^pGevOa_dfbhs^;ivaNq!_OG;o)xVK8P{1eW{v$C6HM1j)JK|m3 zLJ(v$e5@-M2@>la!0e>jdt}i|s;oV)sO@^KdClZ)KDmyeOe=Q7=0NQl-bD`lYtp%o z@TG6sbIA>a&N4aq|BikS{jl-Dw)SM59fjrTV$DXTV4W6)D&06OiyU{)quvo0DFNc$ z6>2djba`1~ZP+v%h#xNFJp|St4xyb7)Ri}?j?`2}bo2u$S@fINZ3i3a~qDwEp+oFHkXbA-t)g@7#}D9D~=#ZY7izvQN?|KzgWu3ckP5r ztlw>rfDR{|*N3*t{Cf}q`*Oh)o+@HSSs=p>91o?~8XlEaPXdWw_cIGG^+t`oP3Q4T zhY+hDaJ8xCY|2p$bxw-~QF6{8ilRhO z$;m`cBB2C?x^vUr@7rgbbN<}#$GtU1kM0(%dg^)hUVE*%=A7#dNZESalnuv0?`tD% zyiIIecvHXS1hKhg!Fa|05gIftUP62fKQKgj$xI;;y7zCF@at7i>V~2&pQ!PG!&I-5 z)Z6hshDYMd6q23-9D91qRH${^i9}tc9&E8j*v!p%g59-QjlV+T8d(WWBs1{hnm{_6 zi~VwqeWy#|bifnd!28?jDbJ&iy?KfKMkf<}CJWJ)(qn%~YOw;r{nwYKMSTN><3ec} zX^ZDm&!Ni7=zI|qpWNro<3$R9+Z@E)Nz4@W!!RLx5Uv8@@9i>z4ey`(0pLo(p0hK0 zfg$4+V=`TsiKqUw3mU3Bohpgf3)+F}ir=DFNlH#)Y%=@l!A{G5>2xBR%F@OYRcls#(C??7li8O{POk|G*g?bxHMAn2l%?XmtB?ZDvct824Y(&Jsz7v1ETB~wi4 zDLr_Da+Nz;#gKku3@MD6h-CbiJOnpQ{hDk6X1Q@>3d|xW%jfOecb}^6I(j%cPpTN%Zdmml1Jrzq=|h$XvOo%=-%DvNIIfcI8|6F z1UV3q>JxRf0ckb^K5GXUcgN3>M2d43ocgqUS0fCcorePoRIr06pBOe1?-sHZpoqNm zSzZ9Z%-(_QKS0_r>Q4DLVd78@vJc+TWIakSHHS}{rR9Wx#4fnE2}U1lJCZ-1|E9Fr zr4tRYEPDB&h2M`w%1vY@k^S<~w`z&~6=#sNh$+;`PClwJ!Fkx8p*sGA)#M2bv(_H( zmQ{bP0{EIwt#;$q`h6Pm&*2l13vCls$cA}$e>irgJ`YU)|RHZ9q{z2&!dEJqP zVb>>l=!A!fYwd6iD+>`p(!-^{74n~ZVZRGx!yLD%OZ4yk-x5b8ty5REpMxPnF4G+2 zA;&pLD-x}u%_PMm)bC$^u%%cosNW}Pf$k0#GJ7CNYn*UIQ=Tm_lwMK)Zc;RyFpxHw zE2W`FKB*rx@sf=(*yqL~uDd4@9en1rKAc=oLcFi=2=v~b-SA>@J#bR`!LUaOY}Y}% z*(MqlKdt^^`vjap?w5ppv;f8^BgDMXh@8$D8Q;K80VEX_a*grCafA_XC(1M z_&#HRw`1yUxx)NYV7$QOK{E6V2VYwefiv0EWfB4S7>|(}#|jQZ+fsZduaUS4+(-AV zzf^H@$(+l8I9kp<2ta{mCZY+3ir(M?pI026A2V&Ln+OZNZqhLH;#wy9LY3o$BGn?E zxgM=9tI`rwRZ{~;-f;hBPon>rokc>Rt5jDhE${X_m6G~AAtPO3M@6j4=e;w78Q-h5 z>ZLWyD9yrfXK(<9aWu}6Kzn%O?dzg?r*J5=p}*OOOt=dt*_l$wpqudz0uVb?r?RSy zckBuA4 zc!{w5RptyK#Q)17ikbQzY22OA1eeo9T9yma!PLSl5Zqtx1$r!D5gP6DmQ@g(%sRL! zmB#x_{q3{yQW&5Su={s%(iOktk zXTtjPx}gp+03XJr^~_SuA9qxx(k$K}GBQ<@y_`^@gW6$H<0b-mpjzGOcSFIGn#{<+ zQGoUl+O02!%$D+6!HsJI}K&1HhxuPbJnn z#Oh>{(pS%*B~skK%n0U0+mafIs3PmignYsy2Ht9-RO>o+pvwY)D0RjPWP?0+EphK? ziw^)h^PqWrHrY?`)8JEWv~^!%Lf^8^Y*WRK5S5a+Z_zx zvL)_1_KG(Sw71v7124j8y?YOYIv8&C))b7eCLnmWe{hb4o+bqWJ=12P=Rp$$D=pJl z)L+V}KR19I7GrOB`g$pH zBB%U@?fcb2qdcPXI9LjKeqKoBn%!`KQZ&9YiKK;E8yFbs2ynu*%6_5v^TG!&pJaIz zmC&aLwy=qcyQ(+OkUT8#Es|1weHGb3;;9qW^TO?rah#`0?9!7HrYuWE0$ilWVRt`) zx$VTj>3OyX7T{su1qZ2*`)+0pk4xBD0Wl3tYCzks@?jeGz$e#Cp|B0QTPFfB2 z=P*;%FGgToN}1K~epy@$x<0lb2Cx3{Non&7#iw7Qna@8*E%JdfsB>}m^pe;*A?ucHLgUNLM+C%lD50wk|3L^zYP@==*$yIyC+zROIYXmJ zT0cYGKhQWxjEv?Foi~S)t}j+UuEJC+EoW~|hCj>;{un;q{=(YD$u|ne+#FY^r3pQx z4b~5qS9=fv8b}g##AOtrTxZfNUuj_76BxMdq+~${q%<8{7a1b?8%QXV`^bPutcT|G zfrZhF=7!WGyHMr46?+{$bF^ClsCgFGX}vBo7z6a*8+Rq2 zE1Pf~W)nyxxV@6}(u(M}blFa~?+Wxkc9j~RmBjh7Mz8Z*v9wz5UQO&p;imMHo-}v` zLP{N4#8nsxE1eUrKXdPI$;+mT?YCTh4}KI<$Qbvn-8_55`mrtslTeTwv?C?%dZC&3 zE)Ke<*U*A1UQierIV8w?&XSE##Mt9k{C+f0pD;r7FS2lMFqi1P-t*zfYxJaKEEMWz z_~BzhV!U~4b|gJ-xnf%<>30Lf<{5Y;@jzQvrJnhSb*s!n^i2{@Hv@2p?>XHZfqX#Z zY!bfQtuqS)`n8@G&`~dd2T1H==iM52B2kFjP?8(^Y$+p^9k^44J9ucJX*}#QRw1?= zHN^{k+lJ-o5}$A48%fQ6-LC5SyXxcP@!$6;t*L2fh2^wY=VYg4A7~iNYxA{JJn~cC zVQ9{4_G)p@gRB8`)_dN;8lz>TXlD6r=hE|3hm;}(i3=T6)i9lx>HSohk6jMzrxOob zv}fC2(eZwD;lLhp>Pd$>EesH^NytK>6;$)xq+kHfz(KZ0Z4fC zv^>2{tClXWR?}>J=H`4UZy(bN#S2R{v@6^l-f!Q<@a>z^f3CURqcv%`-Ld16xpqpX za{I@CU*tutMdb3?>>utJ zXjUG+QnVK!{1Rp^^id(k0eHB(dsy-Co_|04;K%nHoIx+&xl2_~*K;Bu9tA!gp5+7G zP51!eB7a#v9pdt;ULB1rPR~od^bMgWVp^6?_BVh?ckHk2;3IRQA`&Z69oxE(_kH`6 zp<7eoK5Zz$`leFT_Qq=Zt>~1x#L*C`A~pSC5Zf{ziHXa{aHbPK^7WKRm1(t$M9=UH?!Ml!?m=)neq+4~qG?~QEYC-iDHlB$J<0%Jk7*$S$%4bVD`F*nBaI0Mf&Z`` zG2&YNM>SblcNo5>WJ}BwAyLq@u=ucU?w=stVQp|~HE+#`J{m+O|58p*XT{E z-W*8ScCN)w&qCyN^90C=luHGqHAu?m=ZA_8py=SRWr#puGhYtJ8H4mKCw+gzt)tKs zSOIq&MmWsqoF;{DUj(zk{XLFWP35bhyOAeMIU7UXl#sILv@&_>2()>e|H<2-tSw;!JcSvRGwjctJBi{aT3&?6<|ciDVVY6PsYfoY|zRa-BNrUo#A!=gy{61)k)j{iX> zF33|}Rd*u9@UIa8#+VHHvK8=@Ghs!@&fUDO zmemQgroWV@PY#rjL-N4wC-KZ*&>VfO5VxnvWevF*Sh%fiP`y~jDEN}*<*~|^Lgx&o zWq|4Wh+{L6zM#o6VCd4f5$=+kX#})_zNWR143|!TOKkNgAU|wMgyBzw4fCM6Mbn3p zHqrt`w=56beqA;71*+Abfc7QK?7H0hA!?$xylOu-~yM*n~v8w@=6kY*3CncK=)P?4&*$t2o?3 z0hy5+hwm0ZHb*?4>^?aA#YDhN#k~GSh$}h;vjb68aG`b8GtPiM&A*L2YrmYzG0aXn zClh5UbN*+c?V9%|9d+h1z^C4|)(;bG!jNSMN;$yN=%vf}ZHl2sh_Cf?%)l7Llt z-%l3f8lu%spTDl;;AIf38PjW7LO3@xz{d>?AJ(_8h=JacW_s$&`Rb|`ge@pXD zqOP9iq-4_MzS~bZ#8<7&JTke_e4}gjX3q4-^zXA%bHihb+fCozf3U5ms_;>8dH8Nx zUhVzXsfROFJqA6~2HHrCwiH+rUv)<&u8A|*{cDrqYDHYK`qwgloTxavbg4rBdC7|n z_THEEnYJvXA+=CivzY6k@_u(S(vm6L2u4XLFcqgfYf1IVP!UyPRc#veAo(2-f;LAP z9sjDG6v+`4{u<-9sxs6?e>aD0Iwi8yXn0i}m_ObXcDBIdwzFKO?6cel+>> zST7eIp8t^dcO}Y076zD=t=KHNMHhAxo{FTfHduuo%LE-W-0WQmNJMPD;OUh6ap!Gk z)q5tY0*@xTn7HfrLXB)?>%CXTL|t-!U3$;ELaR?53glx)hQ1GZ;`qEkV=t@?hoV_jpM9%8lp_4NHgvn-07*wcP7>DL#FzpxPhsrdM_o zx(Ua~=c3(D;+K7cpL=^PumbziV)SA9(q~a>bQfzU>eRVwUs)w{uI^*sJLOk6Xmh_j z%SjPkd5P$9o8gR0wzBCCqea75w>L{JE1$8FQ2e%_JwtWGu=wKkSG9yKMYa*QnaT`p3@zg*PR{Rb@pY;Vuk-=kQv}}O^P7lTM=1+iKX4bSL(jjjL*@Qe1j=mU#ZM%;++3Q* z>Osed5J$t*M+KE7afWs%C__PT=> znEEE=#B={&=Hl^HZx<|NM*kaM{5Nf$4o~ZUnROy-$xqzxPB{YW z;6&o7Kn_UT8^PyEjfvNy{~AX`%`2tJA9Bza99)$F@@>k%XGffpp04D4%dNl&14}}P z%VL3V+4pQoXeM50C7JO`pGzfY7JOoOxbcf+mP(@pgGPx};1w-L!$%2^Cm9yC;8|NE z=;y!_PX|yjh4up=UnNme5GFhl$X48VD-MimCx*k;GT2xh8y8gvWQ3J;&gE(eCNBh zWLY9CokJMSBx+P}oK@$Q{8h+UUt?-RuSc=dX!HysvjhFk`4JM@I0n4FK`}~#@^BCE z|0WDRNCIVcIGxogn0LEKTm-qHi?S}%eM`55H0Fklu8_#xSQ3$R7IUTKb@Me2og{1; z(jcb5nt1pZHq6<4F}(M_TUrnCFNV?8YKhQ+K)qE<6(RGwlX-8P2MRT}t^+@1S&pOr zMDKq5r&9Qhw8}B8_9T+W8J-26K=D0ai-~{dfPN0t??O*#{`K|N6DQ;$E$D#86k6fe zI9VO%AAAv#jkJ~$bfZQcHseZvp z-q-kKrCeDm9=Ossji-k>`L`N-4dIT%7~h<1WqZv0fOA=woYEGPY; zI9=di85T>7Nw z=%ok%ft*8p?J}H4eRHL_c7-Nk^` zwk^1cB#^w6bY!T_1&%2*k3b{5szAEn?T5hu1gD2zXC7E&wNMf{y9LWW&U8P3sI>=y|ok-vt zqq>f2o=^K2_ufwkiz1{q0tLfKkqqyBlXG8Zsx#F*v-3I0Zp)2D|Ik&_aC+YlapG?#) zZXk|%nF$$yBtGh@%`*TQ~8lz;0!`{Gn6qaro_ym3UPyp*P zn?#T1H*FT(l=f^wlp$dtX9R~%65Nckp}1qAxx$Vi$LHYjKUjvQXpCHirmb+F8a-BcLQ9?ij{It$nB}o1XEz&*1DL)ea*SH zF*C`r<=CNlsAwuN)d*~~7BWUR^n@$*(gB$q#OV)V<9_`0N+6kmV^}ckh=kwA37DkgW*vK6LZef?j9n&I< zn;VKGlV03`E@<+N{$k|r{btf}SYL0ycVwIoR^;e!PCavF#$)ae6I2auYIIKqxoQZ8nUYhI6P)9DY{q5A0L(0x zV09U{+uuDOr>`$RQRd{zVCy@^&amRdBZJ*it$)SY(Kw=82Sh_s5z5Nd!DhSxq4I|u zAg^Zm&l}Q_Cq=i*{!}peh;ccMwsM!QpM@RJX4SZ*q3iX#B{~-0d2n}$JA?mfT{lC{=h7vd{NrzTchNZ!jfR26*-TY zI#PZdJJ{|Uy5C4_kNU5k+L7YuJIJ3z!{`itLJzGOIP@-5AZ&O!EfF!xvw>OW5YQ(3 zpi_)w3ymScZ=M z!6uThrv05Qm|=96RCp8QuMrUav$3dz!T^g*=O%tA*~rD{lSu0G8O~u}th;;>9lpE- z*IgceezVZFgIm`)7>oAWoaMCcf@2i!-blv|TUs3)B2@qdeNi-iza1a-4fxjoJz`-{ z7up}KC=L>FGJ(M?TlsKz2HW&S-CywPT6azcTdM(wxjaGPP&Ira7E5Ft5}dq0lO8?s zO}RBocBOVf8n7}4gw$4)au!HStuAIZUgX0y(3g=@ouTYLxGMQ0(8xMeMy@}lRLdp` z)Z{anaY&|6!}07hz^tCoowXopGOb?-jv=yq%1;|e>l}W(IVd=f@%5sQ~%-_ zV(kv5Z;Gi1Cz;pJzpIjCNaO#6;J4GwI<6}o;gIH_2D9J1l-X<~Jq#1>U z<-1#J=Vnhl+;xU9)7i$U?{1ai@G~Y&djV*m2*jCFOE{;XHKq|@muXzeAlI1GxCoVP zaCdWh6BYgRBtcz%ogmTbo#w1vN^;d_Swo5mNZOtUe zk-EsdB++sY#So;hH$36ZI+#ogO681!O$xFL?Y)n1D72t`ZJ=d>)3GfZC_^(&O{W?SgesgQ^PoF(q8FrPVmxpv9hrG9%p|1}U&82LfVIUFhesnrF40Q8nQ zwUYM2<^xQ^8{yZ+>EnM#C`~@pjl|7M;g7owNeX6M(la#Kl!kYTp{hQ_Y6$UCzTP+^x%5D`H|cza`{i zrd3yN%ey*gbK4?4<75ob`?w|&M{(Zwk71z{%*l(8ir0aZLN$AZc*?0QD7md?LgA6h^&TI2voAq?k%1C%vHLMrJ@E`%qDSLZ1;27Lx;S;iopAYcG?>;f zR#^A!tpY_DfG30S@Q~=_AWY z0s|ts`+0t%(Gg^}ZSKS!Pu&nsGv!$is$vh^-0J6d5btNNsK%R^O^t`j(J|sdZ^j zmcB-Und|ifotQ1=EGzFzb1w8OF;kiYZYPx+FKlv_q|q`g8Vu^qIYcb$O=hSS%x`Ek zd;?QU(p~HoR1_9>5q2NZxkecSx^1gPP_Y+cj*oEpv7B+;r^O42(Gl8I`K{n!Hh=dC zF=YSv?y^GPuG4x-)TJFBGnRw|G4H)n^LIJMp~|Xo;i4@LRSVFnr4BpI{c#S9jjwtc z9v_j;?Z>jT*Ahq{$xcZ+j;WYkMnV3ey9N|K_vzAB^5+Mc?L=>gHvcdV$tc@fiM<=P z-=gjK3pl`MCdQD9Jb8C>^5X_}0^KRzd}Avzg}F*Jx-;1J_V3kVFbp$^?o7ykCKm@% z+fE1p^*O8NkiO1wc3}3&_ucag&yexTIwccL6fkuYyBAh1k zyuQn0iNi)r9u?OET$Xj_ax(C=!OY)qWb#K=8!SD|VCB3S5^@ zbsK$|in2RaDAgxOT_$d>&Ksn@WT}V?7t1~40(f^zr;gqhU9Z!6C6<2jZ0}RS;wNh4 z^BGq7r7zJ=(xVbjCsOApD%2n2-(?e}_+_nLz8h(K0ZeW0A^;66&3om<$Vh3+Juc(% zm`?RxGNNG)cVV>CuyuToPwea!%F*XFHFARL0`H&*&o`Bl3oY~8##T%1XKfNwiyu48 zepAy_>3$}F|A5q#N3<6=bbxh7jdZdNFK#_vguKFP$rv~ciC|<%v0cJ3Vl{(ASg^}d z%CN9^ByJ9CriIvT_A6hvbBMl5t3#C!T$h0>_f!T;y&(*f?&vq{LUT{sck3-5Z&u-W z(+7+!7`1fihz!f9sNeiBwG6Y@OdmfUXA=K@MOu8KtKLYH`Vh9)1Ss+qryI2yy~H*tUZ%R(XlrY?@`j}XA>ZVTkZ3u z9^ijvO}vz#bBxcclu;sWZu=X&6b|L`QWMCOAiQ56)(4`kqn#|>*%dCIwWB;|W?u24 zhSuDqzLDXxz&zH2?r}3|8NiJdkQ8{@LpW=s%!$n!XZjVB=tO0viO;Jjr4>yLVt{@V z6YTOq;x&f?B5+3H!gK26^cUskKr`_elY098a-rmZKnvj?q7v%Lb}}bM?;$az)|vTx z5X3mt2B|e=NR28G!1px%ZM4)gw1M~Sj9Ch&GMu^J8Lw0@-VE9^oco%(?}dKGuu+%^ zp+){4xbvdQyFy6xWy;2nh_P8Ve~pB}V_AEsi$T+R>#uYZrrvu)J?Ktse-h8=$|@}$ zk%qH<&JrqFo6>jKL7yb`Z%Q(4Nq{3OA&8W(w$OT7p3oR?vh4gnnGMIKd-6O@PY+Ly zbdH27V17p z^4|OtzAOI$bXaz?Ha!=+5z3QrJv#q(!lW_6OcE*{tH<&Aa-NWVB-h;zRdxM*_@Kap z`rEpdH;b0_M8}C&xGmK}#BSH#%EfV-{b0SNrdI4tMI4dI=JvZ(Q@y@nRLx*R0-k#d zVf!+ly7+=DQ5^l!3+f|XagUQ*oq^_3*x#UdnI2fdY>wk$xPb3@uY?*}vJ~3H?+=7r zC=axORAt(S)RjNJta@*A&v*gI32t=<@6{XXKmY4puSyh^*ROa>jtgAA2?pwTlNeWj z

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 Date: Tue, 28 Jul 2026 00:35:39 +0200 Subject: [PATCH 32/86] bench: drop SHUFFLE for the string rows too SHUFFLE de-interleaves byte positions within an item -- right for numeric data, where byte 0 of every float is a column of similar values, and pointless for text, where it just scatters each string across its slot. Turning it off is faster than any shuffle width on every task. transform at 1M rows, LZ4-5, blosc2 alone in the process: no filters 44.0 ms 2.8 MB SHUFFLE, width 4 49.3 ms 2.7 MB SHUFFLE, width = itemsize 75.8 ms 6.6 MB filter shows it most, 7.3 ms against 11.9. It also removes the filters_meta trap from the config entirely: that field is only consulted for the filter it belongs to, so with SHUFFLE gone there is nothing to get wrong. Full 24.3M table, against the SHUFFLE-width-4 numbers it replaces: filter 297 -> 167 ms transform 1.22 -> 1.10 s kernel 3.09 -> 3.11 s result 68 -> 68 MB That is 2.0x DuckDB on filter, 1.80x on transform, and within 1.06x on kernel. Co-Authored-By: Claude Opus 5 --- bench/chicago-taxi/README.md | 72 +++++++++++++++++++----------- bench/chicago-taxi/string-ops.png | Bin 68420 -> 68247 bytes bench/chicago-taxi/string-ops.py | 29 +++++++----- 3 files changed, 63 insertions(+), 38 deletions(-) diff --git a/bench/chicago-taxi/README.md b/bench/chicago-taxi/README.md index 1a00d0eeb..d2671515b 100644 --- a/bench/chicago-taxi/README.md +++ b/bench/chicago-taxi/README.md @@ -99,34 +99,58 @@ row-wise control flow rather than one expression. blosc2 runs it as a fully-evaluated branches. `--apply` adds the row-wise pandas spelling, which is what you would write first and is ~70x slower than everything else. -**blosc2 uses LZ4 at `clevel=5`**, not the ZSTD-5 default: on this workload it -is ~1.6x faster to write for ~3.6x more stored bytes, which is still 13x below -what the Arrow-backed engines hold. **`blosc2 (raw)` is the identical path at -`clevel=0`** — same container, same kernel, same filter pipeline, operands and -result both uncompressed. Compression is the only variable between the two -blosc2 bars. +**blosc2 uses LZ4 at `clevel=5` with no filters**, rather than the stock +ZSTD-5 + SHUFFLE. LZ4 is ~1.6x faster to write for ~3.6x more stored bytes, +still 13x below what the Arrow-backed engines hold; dropping SHUFFLE is +explained under the results. **`blosc2 (raw)` is the identical path at +`clevel=0`** — same container, same kernel, same (empty) filter pipeline, +operands and result both uncompressed. Compression is the only variable between +the two blosc2 bars. Results on an Apple M-series laptop (8 cores, 24 GB), full table, warm (see `string-ops.png`): | | filter | transform | kernel | kernel result | |---|---|---|---|---| -| **blosc2** | **297 ms** | **1.22 s** | 3.09 s | **68 MB** | -| blosc2 (raw) | 187 ms | 1.39 s | 3.58 s | 5 766 MB | -| pandas | 192 ms | 2.07 s | 5.28 s | 932 MB | -| polars | 93 ms | 1.75 s | 3.87 s | 932 MB | -| duckdb | 344 ms | 1.98 s | 2.96 s | 842 MB | - -blosc2 is **fastest of all five on `transform`** (1.62x DuckDB), ahead of DuckDB -on `filter`, and within 1.04x on `kernel` — while holding the result in **14x +| **blosc2** | **167 ms** | **1.10 s** | 3.11 s | **68 MB** | +| blosc2 (raw) | 224 ms | 1.21 s | 3.40 s | 5 766 MB | +| pandas | 193 ms | 2.07 s | 5.28 s | 932 MB | +| polars | 92 ms | 1.74 s | 3.83 s | 932 MB | +| duckdb | 337 ms | 1.98 s | 2.94 s | 842 MB | + +blosc2 is **fastest of all five on `transform`** (1.80x DuckDB), **2.0x DuckDB +on `filter`**, and within 1.06x on `kernel` — while holding the result in **12x less memory** than any of them. Only polars' `filter` is faster. -**Compression is now free, and then some.** Compare the two blosc2 rows: the -compressed run is *faster* than the uncompressed one on both `transform` -(1.22 s vs 1.39) and `kernel` (3.09 s vs 3.58), because a compressed block is -less memory traffic than a 5.8 GB uncompressed result. It also stores 85x -smaller. `filter` is the one exception — a bool result is 1 byte per row, so -there is no output-side win to offset the operand reads. +**Compression is free, and then some.** Compare the two blosc2 rows: the +compressed run is *faster* than the uncompressed one on all three tasks, +because a compressed block is less memory traffic than a 5.8 GB uncompressed +result. It also stores 85x smaller. + +### Why no filters + +The default pipeline ends in SHUFFLE, which de-interleaves byte positions +*within* an item. That is exactly right for numeric data — byte 0 of every +float is a column of similar values — and pointless for text, where it only +scatters each string across its slot. On `transform`, 1 M rows, LZ4-5, +blosc2 alone in the process: + +| | time | stored | +|---|---|---| +| **no filters** | **44.0 ms** | 2.8 MB | +| SHUFFLE, width 4 | 49.3 ms | 2.7 MB | +| SHUFFLE, width = itemsize | 75.8 ms | 6.6 MB | + +`filter` shows it most: 7.3 ms against 11.9. The third row is a trap worth +knowing about: `filters_meta` is SHUFFLE's element width, a `n8HR`= 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∨bsj2vO~l=UASF~W;6xK!J7>=2 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 BaRUGV literal 68420 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 z

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 Date: Tue, 28 Jul 2026 00:44:07 +0200 Subject: [PATCH 33/86] bench: present the filter choice as the tradeoff it is Co-Authored-By: Claude Opus 5 --- bench/chicago-taxi/README.md | 52 +++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/bench/chicago-taxi/README.md b/bench/chicago-taxi/README.md index d2671515b..96b5da693 100644 --- a/bench/chicago-taxi/README.md +++ b/bench/chicago-taxi/README.md @@ -100,9 +100,9 @@ fully-evaluated branches. `--apply` adds the row-wise pandas spelling, which is what you would write first and is ~70x slower than everything else. **blosc2 uses LZ4 at `clevel=5` with no filters**, rather than the stock -ZSTD-5 + SHUFFLE. LZ4 is ~1.6x faster to write for ~3.6x more stored bytes, -still 13x below what the Arrow-backed engines hold; dropping SHUFFLE is -explained under the results. **`blosc2 (raw)` is the identical path at +ZSTD-5 + SHUFFLE. Both are throughput-for-ratio trades and both are explained +under the results; the result is still 12x smaller than what the Arrow-backed +engines hold. **`blosc2 (raw)` is the identical path at `clevel=0`** — same container, same kernel, same (empty) filter pipeline, operands and result both uncompressed. Compression is the only variable between the two blosc2 bars. @@ -127,25 +127,33 @@ compressed run is *faster* than the uncompressed one on all three tasks, because a compressed block is less memory traffic than a 5.8 GB uncompressed result. It also stores 85x smaller. -### Why no filters - -The default pipeline ends in SHUFFLE, which de-interleaves byte positions -*within* an item. That is exactly right for numeric data — byte 0 of every -float is a column of similar values — and pointless for text, where it only -scatters each string across its slot. On `transform`, 1 M rows, LZ4-5, -blosc2 alone in the process: - -| | time | stored | -|---|---|---| -| **no filters** | **44.0 ms** | 2.8 MB | -| SHUFFLE, width 4 | 49.3 ms | 2.7 MB | -| SHUFFLE, width = itemsize | 75.8 ms | 6.6 MB | - -`filter` shows it most: 7.3 ms against 11.9. The third row is a trap worth -knowing about: `filters_meta` is SHUFFLE's element width, a ` Date: Tue, 28 Jul 2026 00:53:26 +0200 Subject: [PATCH 34/86] bench: keep SHUFFLE at the UCS4 width, take throughput from the codec Shuffle by 4 is what a --- bench/chicago-taxi/README.md | 46 ++++++++++++++++-------------- bench/chicago-taxi/string-ops.png | Bin 68247 -> 47217 bytes bench/chicago-taxi/string-ops.py | 40 ++++++++++++++------------ 3 files changed, 46 insertions(+), 40 deletions(-) diff --git a/bench/chicago-taxi/README.md b/bench/chicago-taxi/README.md index 96b5da693..eacadb7a8 100644 --- a/bench/chicago-taxi/README.md +++ b/bench/chicago-taxi/README.md @@ -99,10 +99,10 @@ row-wise control flow rather than one expression. blosc2 runs it as a fully-evaluated branches. `--apply` adds the row-wise pandas spelling, which is what you would write first and is ~70x slower than everything else. -**blosc2 uses LZ4 at `clevel=5` with no filters**, rather than the stock -ZSTD-5 + SHUFFLE. Both are throughput-for-ratio trades and both are explained -under the results; the result is still 12x smaller than what the Arrow-backed -engines hold. **`blosc2 (raw)` is the identical path at +**blosc2 uses LZ4 at `clevel=5`**, rather than the stock ZSTD-5 — a +throughput-for-ratio trade, and the result is still 12x smaller than what the +Arrow-backed engines hold. SHUFFLE stays on at the `J0)i0%em7;pZ{H2`3l6%d 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 z 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

Qgu{BzCUk1 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 literal 68247 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∨bsj2vO~l=UASF~W;6xK!J7>=2 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 BaRUGV diff --git a/bench/chicago-taxi/string-ops.py b/bench/chicago-taxi/string-ops.py index 2908b5c2f..90b7a360e 100644 --- a/bench/chicago-taxi/string-ops.py +++ b/bench/chicago-taxi/string-ops.py @@ -58,29 +58,33 @@ # asarray() picks for itself when left alone) they are 15.0 / 79.7 / 154.0. CHUNKS, BLOCKS = (1 << 16,), (512,) -# LZ4 rather than the ZSTD-5 default: on this workload it is ~1.6x faster to -# write for ~3.6x more stored bytes, which is still 13x smaller than what the -# Arrow-backed engines hold. +# LZ4-5 rather than the ZSTD-5 default: ~1.6x faster to write for ~3.6x more +# stored bytes, and 68 MB against DuckDB's 842 either way. # -# And no filters at all. The default pipeline ends in SHUFFLE, which -# de-interleaves byte positions *within* an item -- sensible for numeric data, -# where byte 0 of every float is a column of similar values, and pointless for -# text, where it just scatters each string across the slot. Measured on -# `transform`, 1 M rows, LZ4-5: +# SHUFFLE stays on with filters_meta 4, which is what a Date: Tue, 28 Jul 2026 01:05:57 +0200 Subject: [PATCH 35/86] bench: regenerate the plot from a full all-engines run The committed plot had been overwritten by blosc2-only tuning runs. Table refreshed from the same run. Co-Authored-By: Claude Opus 5 --- bench/chicago-taxi/README.md | 25 ++++++++++++------------- bench/chicago-taxi/string-ops.png | Bin 47217 -> 67168 bytes 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/bench/chicago-taxi/README.md b/bench/chicago-taxi/README.md index eacadb7a8..c4832c230 100644 --- a/bench/chicago-taxi/README.md +++ b/bench/chicago-taxi/README.md @@ -112,19 +112,18 @@ Results on an Apple M-series laptop (8 cores, 24 GB), full table, warm | | filter | transform | kernel | kernel result | |---|---|---|---|---| -| **blosc2** | 299 ms | **1.48 s** | 3.09 s | **68 MB** | -| blosc2 (raw) | 177 ms | 1.36 s | 3.62 s | 5 766 MB | -| pandas | 194 ms | 2.07 s | 5.31 s | 932 MB | -| polars | 92 ms | 1.74 s | 3.72 s | 932 MB | -| duckdb | 335 ms | 1.97 s | 2.95 s | 842 MB | - -blosc2 is **fastest of all five on `transform`**, ahead of DuckDB on `filter`, -and within 1.05x on `kernel` — while holding the result in **12x less memory** -than any of them. Only polars' `filter` is faster. - -`transform` is the noisiest row: blosc2 alone in the process gives 1.24–1.26 s -over repeated runs against the 1.48 s above, so read it as ~1.3 s and quote the -ratio, not the absolute. +| **blosc2** | 315 ms | **1.27 s** | 3.08 s | **68 MB** | +| blosc2 (raw) | 192 ms | 1.31 s | 3.57 s | 5 766 MB | +| pandas | 191 ms | 2.00 s | 5.11 s | 932 MB | +| polars | 92 ms | 1.74 s | 3.53 s | 932 MB | +| duckdb | 344 ms | 2.01 s | 2.97 s | 842 MB | + +blosc2 is **fastest of all five on `transform`** (1.58x DuckDB), ahead of DuckDB +on `filter`, and within 1.04x on `kernel` — while holding the result in **12x +less memory** than any of them. Only polars' `filter` is faster. + +Quote ratios rather than absolutes: `transform` moved between 1.27 and 1.48 s +across full-table runs, and blosc2 alone in the process gives 1.24–1.26 s. **Compression is close to free.** Compare the two blosc2 rows: the compressed run is *faster* than the uncompressed one on `kernel` (3.09 s vs 3.62), because diff --git a/bench/chicago-taxi/string-ops.png b/bench/chicago-taxi/string-ops.png index bc671faf0cf0706c9e051f2b12d7fe128ce0acca..36dbcacb5d79a65ea6e5ef5f22fa226976e09524 100644 GIT binary patch literal 67168 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 z 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

Qgu{BzCUk1 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 From c5dc1d0fd11fedaffe179cf5d40b6f9c20a0903b Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 28 Jul 2026 01:06:42 +0200 Subject: [PATCH 36/86] Add the regression test for the 4096-element eval-block bug Pins a block wider than one miniexpr eval block (ME_EVAL_BLOCK_NITEMS). The assertion that arr.blocks[0] > 4096 is the part that matters -- an auto-chosen geometry would silently stop exercising the path. Fixed in miniexpr a6a694d. Co-Authored-By: Claude Opus 5 --- tests/ndarray/test_string_output.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/ndarray/test_string_output.py b/tests/ndarray/test_string_output.py index 09726d550..e8f3c455c 100644 --- a/tests/ndarray/test_string_output.py +++ b/tests/ndarray/test_string_output.py @@ -220,3 +220,17 @@ def test_wide_string_operands_match_numpy(width): upper = blosc2.upper(arr).compute(strict_miniexpr=True) assert list(upper[:]) == list(np.strings.upper(values)) + + +def test_block_larger_than_the_eval_block(): + # miniexpr splits a block into ME_EVAL_BLOCK_NITEMS (4096) element chunks and + # advances the output pointer by dtype_size(), which is 0 for ME_STRING -- so + # every eval chunk after the first landed back on element 0 and everything + # past 4096 stayed as allocated. Silent: values 0..4095 looked right. + n = 20000 + values = np.array([f"row{i:05d}" for i in range(n)], dtype=" 4096, "need a block wider than one miniexpr eval block" + + got = ("x=" + arr).compute(strict_miniexpr=True) + assert list(got[:]) == list("x=" + values) From bf9b4846828bc7492adc20aece3aa99cd2646990 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 28 Jul 2026 01:16:48 +0200 Subject: [PATCH 37/86] Repoint miniexpr at bd2c602 The previous pin (5a7de4f) was orphaned by an amended commit message on the miniexpr branch; the code is identical. Pinned to the branch tip this time so the pin stays reachable. Co-Authored-By: Claude Opus 5 --- CMakeLists.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 18650ab7a..c6cc48ac3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -110,7 +110,7 @@ endif() FetchContent_Declare(miniexpr GIT_REPOSITORY https://github.com/Blosc/miniexpr.git - GIT_TAG 5a7de4f5e493834970abdd2bce089c1d99f643d0 + GIT_TAG bd2c602a652c50b306def625c5fe5491cbd13f76 # SOURCE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/../miniexpr ) FetchContent_MakeAvailable(miniexpr) From a9446841aeda64e2d0d3b67b8e71d36545d503ce Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 28 Jul 2026 19:04:01 +0200 Subject: [PATCH 38/86] Cache the dictionary code->value map instead of decoding per row DictionaryColumn.decode() indexed the dict store once per code, and every such index decompresses a whole msgpack batch, so decoding N rows cost N batch decompressions. _ensure_cache() already read the entire dictionary to build the value->code map, so the reverse list comes free in the same pass; decode() now indexes it, encode() appends to both, and decode_batch() builds off the cache rather than re-reading the store. At 1M rows / cardinality 20k: unindexed sort_by 235970 ms -> 713 ms, full column read 193 ms. group_by was already fast because it is the only caller that used decode_batch(). Co-Authored-By: Claude Opus 5 --- src/blosc2/dictionary_column.py | 19 +++++++++++--- tests/ctable/test_dictionary_column.py | 36 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/blosc2/dictionary_column.py b/src/blosc2/dictionary_column.py index 537978337..f9b2bfc67 100644 --- a/src/blosc2/dictionary_column.py +++ b/src/blosc2/dictionary_column.py @@ -57,24 +57,32 @@ def __init__(self, spec: DictionarySpec, codes, dict_store: _ScalarVarLenArray) self._dict_store = dict_store # _ScalarVarLenArray of vlstring (unique values) # Cache: str → int32 code. Built lazily from dict_store on first access. self._value_to_code: dict[str, int] | None = None + # Reverse cache: code → str, same lazy build. Indexing dict_store per + # code decompresses a whole msgpack batch every time, so decoding N rows + # that way costs O(N) batch decompressions instead of one dictionary read. + self._code_to_value: list[str] | None = None # ------------------------------------------------------------------ # Cache management # ------------------------------------------------------------------ def _ensure_cache(self) -> None: - """Build the value→code mapping from the persisted dict_store.""" + """Build the value→code and code→value mappings from the persisted dict_store.""" if self._value_to_code is not None: return self._dict_store.flush() cache: dict[str, int] = {} + values: list[str] = [] for code, value in enumerate(self._dict_store): + values.append(value) if value is not None: cache[value] = code self._value_to_code = cache + self._code_to_value = values def _invalidate_cache(self) -> None: self._value_to_code = None + self._code_to_value = None # ------------------------------------------------------------------ # Encoding / decoding @@ -101,6 +109,8 @@ def encode(self, value: str | None) -> int: ) self._dict_store.append(value) self._value_to_code[value] = new_code + assert self._code_to_value is not None + self._code_to_value.append(value) return new_code def decode(self, code: int) -> str | None: @@ -108,7 +118,8 @@ def decode(self, code: int) -> str | None: if code == self._spec.null_code: return None self._ensure_cache() - return self._dict_store[int(code)] + assert self._code_to_value is not None + return self._code_to_value[int(code)] def decode_batch(self, codes) -> list[str | None]: """Decode an array of int32 *codes* to a list of strings (``None`` for null codes). @@ -119,8 +130,8 @@ def decode_batch(self, codes) -> list[str | None]: is dramatically cheaper than looping over :meth:`decode`. """ codes = np.asarray(codes) - self._dict_store.flush() - all_strings = np.asarray(self._dict_store[:]) # D unique values, no nulls + self._ensure_cache() + all_strings = np.asarray(self._code_to_value) # D unique values, no nulls null_code = int(self._spec.null_code) result: list[str | None] = [None] * len(codes) non_null_idx = np.nonzero(codes != null_code)[0] diff --git a/tests/ctable/test_dictionary_column.py b/tests/ctable/test_dictionary_column.py index 071357d4c..0c0523347 100644 --- a/tests/ctable/test_dictionary_column.py +++ b/tests/ctable/test_dictionary_column.py @@ -538,5 +538,41 @@ def test_cli_dict_export_roundtrip(tmp_path): assert rt.column("score").to_pylist() == [1, 2, 3, 4] +def test_decode_reads_dictionary_once_not_per_row(): + """Decoding N rows must not index the dict store N times. + + Each ``dict_store[code]`` decompresses a whole msgpack batch, so a per-code + decode makes reads and lexsort-based ``sort_by`` cost O(N) decompressions. + """ + + @dataclass + class Row: + c: str = blosc2.field(blosc2.dictionary()) + + t = CTable(Row) + t.extend({"c": [f"v{i % 50}" for i in range(2000)]}, validate=False) + t._flush_varlen_columns() + + col = t._cols["c"] + col._invalidate_cache() + store = col._dict_store + original = type(store).__getitem__ + calls = 0 + + def counting_getitem(self, key): + nonlocal calls + calls += 1 + return original(self, key) + + type(store).__getitem__ = counting_getitem + try: + values = col[0:2000] + finally: + type(store).__getitem__ = original + + assert values == [f"v{i % 50}" for i in range(2000)] + assert calls <= 1, f"decoded 2000 rows with {calls} dict-store reads" + + if __name__ == "__main__": pytest.main(["-v", __file__]) From 92c39aa66554d5e80b3acdabcf9ae6abec9e43f5 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 28 Jul 2026 19:13:08 +0200 Subject: [PATCH 39/86] Stop bucket indexes from costing more than the scan they replace Two independent problems made kind=BUCKET slower than no index at all on a selective range query whose matches scatter across the column. Both are element-type agnostic and both affected every indexable dtype. Reads were issued one contiguous bucket run at a time, so scattered matches re-decompressed each block many times: 1379 spans averaging 320 elements over a column with only 128 blocks. _coalesce_spans() merges spans separated by less than one block; widening a span only hands more rows to the predicate, which rejects them, and merged spans stay disjoint and ordered so positions stay unique and sorted. The planner then measured selectivity in buckets while the cost is paid in blocks. A mask selecting 21% of buckets touched 96% of blocks, so it took a plan that read the whole column in scattered pieces. _bucket_block_fraction() reports the fraction of blocks a mask forces a read of, and plans above _BUCKET_MAX_BLOCK_FRACTION are declined in favour of the linear scan. Scattered 0.1% range over 2M rows, no index -> bucket, before and after: int32 4.9 ms -> 45.6 ms now 5.5 ms (declined, 0.97) int64 5.8 ms -> 56.9 ms now 6.5 ms (declined, 0.96) float64 6.3 ms -> 77.9 ms now 6.6 ms (declined, 0.96) 187.3 ms now 41.3 ms (declined, 0.96) 228.2 ms now 58.0 ms (declined, 0.96) 274.6 ms now 97.6 ms (declined, 0.96) The relative penalty was worst on the numeric dtypes, whose baseline scan is fast enough that redundant block decompression dominates outright; the strings merely showed the largest absolute numbers. Clustered data, where the index has real pruning to offer, still uses it and still wins: int64 3.7 -> 2.4 ms, float64 2.9 -> 1.8 ms, 2.7 ms. Co-Authored-By: Claude Opus 5 --- src/blosc2/indexing.py | 59 +++++++++++++++++++-- tests/ctable/test_ctable_indexing.py | 76 ++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 3 deletions(-) diff --git a/src/blosc2/indexing.py b/src/blosc2/indexing.py index e4e5f9711..f1c2f0cc5 100644 --- a/src/blosc2/indexing.py +++ b/src/blosc2/indexing.py @@ -6223,6 +6223,26 @@ def _bucket_match_from_span(span: np.ndarray, plan: IndexPlan) -> np.ndarray: return match +def _coalesce_spans(spans: list[tuple[int, int]], max_gap: int) -> list[tuple[int, int]]: + """Merge spans separated by fewer than *max_gap* elements. + + A read decompresses whole blocks, so two spans landing in the same block pay + for it twice unless they are merged. Widening a span only hands more rows to + the predicate, which rejects them; merged spans stay disjoint and ordered, so + positions remain unique and sorted. + """ + if max_gap <= 0 or len(spans) < 2: + return spans + merged = [spans[0]] + for start, stop in spans[1:]: + last_start, last_stop = merged[-1] + if start - last_stop < max_gap: + merged[-1] = (last_start, max(last_stop, stop)) + else: + merged.append((start, stop)) + return merged + + def _process_bucket_chunk_batch( chunk_ids: np.ndarray, where_x, @@ -6233,15 +6253,19 @@ def _process_bucket_chunk_batch( value_parts = [] position_parts = [] local_where_x = _bucket_worker_source(where_x) + blocks = getattr(local_where_x, "blocks", None) + block_len = int(blocks[0]) if blocks else 0 for chunk_id in chunk_ids: bucket_mask = plan.bucket_masks[int(chunk_id)] chunk_start = int(chunk_id) * plan.chunk_len chunk_stop = min(chunk_start + plan.chunk_len, total_len) + spans = [] for run_start, run_stop in _contiguous_true_runs(np.asarray(bucket_mask, dtype=bool)): start = chunk_start + run_start * plan.bucket_len stop = min(chunk_start + run_stop * plan.bucket_len, chunk_stop) - if start >= stop: - continue + if start < stop: + spans.append((start, stop)) + for start, stop in _coalesce_spans(spans, block_len): if _supports_block_reads(local_where_x): span = np.empty(stop - start, dtype=local_where_x.dtype) _read_ndarray_linear_span(local_where_x, start, span) @@ -6823,6 +6847,32 @@ def _plan_multi_exact_query(plans: list[ExactPredicatePlan]) -> IndexPlan | None return None +#: Decline a bucket plan that would touch more than this fraction of the column's +#: blocks. Buckets are far smaller than blocks, so a mask can select few buckets +#: and still force a read of nearly every block — at which point the scattered +#: reads cost more than the linear scan the index is meant to replace. +_BUCKET_MAX_BLOCK_FRACTION = 0.5 + + +def _bucket_block_fraction(bucket_masks: np.ndarray, bucket: dict) -> float: + """Fraction of the column's blocks that *bucket_masks* forces a read of. + + Selectivity in buckets overstates what the index saves: a read decompresses a + whole block, so the cost unit is the block, not the bucket. + """ + masks = np.asarray(bucket_masks, dtype=bool) + if masks.size == 0: + return 0.0 + per_block = max(1, int(bucket["nav_segment_len"]) // int(bucket["bucket_len"])) + if per_block <= 1: + return float(masks.any(axis=1).mean()) if masks.ndim > 1 else 1.0 + n_blocks = math.ceil(masks.shape[-1] / per_block) + padded = np.zeros((*masks.shape[:-1], n_blocks * per_block), dtype=bool) + padded[..., : masks.shape[-1]] = masks + blocks_hit = padded.reshape(*masks.shape[:-1], n_blocks, per_block).any(axis=-1) + return float(blocks_hit.mean()) + + def _plan_single_exact_query(exact_plan: ExactPredicatePlan) -> IndexPlan: kind = exact_plan.descriptor["kind"] if kind in {"full", "opsi"}: @@ -6872,7 +6922,10 @@ def _plan_single_exact_query(exact_plan: ExactPredicatePlan) -> IndexPlan: bucket = exact_plan.descriptor["bucket"] total_units = bucket_masks.size selected_units = _bit_count_sum(bucket_masks) - if selected_units < total_units: + if ( + selected_units < total_units + and _bucket_block_fraction(bucket_masks, bucket) <= _BUCKET_MAX_BLOCK_FRACTION + ): return IndexPlan( True, "bucket approximate-order index selected", diff --git a/tests/ctable/test_ctable_indexing.py b/tests/ctable/test_ctable_indexing.py index 32dfd8f0f..e64da9487 100644 --- a/tests/ctable/test_ctable_indexing.py +++ b/tests/ctable/test_ctable_indexing.py @@ -1219,3 +1219,79 @@ def test_wide_sidecar_span_read_is_not_short(): out = np.empty(100, dtype=dtype) arr.get_1d_span_numpy(out, 1, 5, 100) assert out.tolist() == values[128 + 5 : 128 + 105].tolist() + + +def test_coalesce_spans_merges_within_a_block(): + """Spans closer than one block must merge: reading them apart re-reads the block.""" + coalesce = blosc2.indexing._coalesce_spans + spans = [(0, 100), (200, 300), (50_000, 50_100)] + assert coalesce(spans, 1024) == [(0, 300), (50_000, 50_100)] + assert coalesce(spans, 0) == spans # unknown block size → leave alone + assert coalesce([(0, 10)], 1024) == [(0, 10)] + # merged spans stay disjoint and ordered, so gathered positions stay unique + merged = coalesce([(0, 100), (10, 400), (401, 402)], 1024) + assert merged == [(0, 402)] + + +def test_bucket_block_fraction_counts_blocks_not_buckets(): + """Selectivity in buckets overstates the saving; the read unit is the block.""" + frac = blosc2.indexing._bucket_block_fraction + geom = {"nav_segment_len": 16384, "bucket_len": 256} # 64 buckets per block + + scattered = np.zeros((1, 640), dtype=bool) + scattered[0, ::64] = True # 1.6% of buckets, but one in every block + assert frac(scattered, geom) == 1.0 + + clustered = np.zeros((1, 640), dtype=bool) + clustered[0, 0:64] = True # 10% of buckets, all inside one block + assert frac(clustered, geom) == 0.1 + + assert frac(np.zeros((1, 640), dtype=bool), geom) == 0.0 + + +def test_bucket_plan_gate_matches_block_fraction(): + """The planner must take a bucket plan only when it prunes actual blocks.""" + rng = np.random.default_rng(0) + n, card = 200_000, 5_000 + pool = sorted(f"v-{i:05d}" for i in range(card)) + + @dataclasses.dataclass + class Row: + c: str = blosc2.field(blosc2.string(max_length=8)) + v: float = blosc2.field(blosc2.float64()) + + def table(values, kind): + t = blosc2.CTable(Row) + t.extend({"c": values, "v": rng.random(n)}, validate=False) + if kind: + t.create_index("c", kind=kind) + return t + + query = f"(c >= '{pool[100]}') & (c < '{pool[120]}')" + values = [pool[i] for i in rng.integers(0, card, n)] + + seen = [] + original = blosc2.indexing._plan_single_exact_query + + def capture(exact_plan): + plan = original(exact_plan) + if plan.bucket_masks is not None: + fraction = blosc2.indexing._bucket_block_fraction( + plan.bucket_masks, exact_plan.descriptor["bucket"] + ) + seen.append((plan.usable, fraction)) + return plan + + blosc2.indexing._plan_single_exact_query = capture + try: + indexed = sorted(table(values, "bucket").where(query)["c"][:].tolist()) + finally: + blosc2.indexing._plan_single_exact_query = original + + assert seen, "no bucket plan was considered" + for usable, fraction in seen: + gate = blosc2.indexing._BUCKET_MAX_BLOCK_FRACTION + assert usable == (fraction <= gate), f"took={usable} at block fraction {fraction}" + + # Whichever way the gate goes, the answer is the same as an unindexed scan. + assert indexed == sorted(table(values, None).where(query)["c"][:].tolist()) From 12c5629ff19d4d93c11e5f708e3afc7d07eace6c Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 28 Jul 2026 19:32:07 +0200 Subject: [PATCH 40/86] Assess the string column flavours against measurements Written to evaluate whether utf8() should keep chasing compute parity with --- plans/string-flavours-assessment.md | 291 ++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 plans/string-flavours-assessment.md diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md new file mode 100644 index 000000000..20ab2e3aa --- /dev/null +++ b/plans/string-flavours-assessment.md @@ -0,0 +1,291 @@ +# String column flavours: what works today, and what to do about utf8 + +Measured on branch `dsl-string-support`, blosc2 `4.9.2.dev0`, NumPy 2.4.6. Every cell below was +probed on a live `CTable`, not read off the docs — the doc table in `doc/reference/ctable.rst` +(§ChoosingStringType) is accurate but predates the compute surface and omits it. + +**All timings are post-`92c39aa6`**, i.e. they include the two performance bugs this assessment +turned up and which have since been fixed: the dictionary per-row decode (`a9446841`) and the bucket +index pessimization (`92c39aa6`). Where a number changed materially the old value is kept alongside, +because two of the conclusions below originally rested on it. + +## Capability matrix + +| | `string()` ` Date: Tue, 28 Jul 2026 19:53:35 +0200 Subject: [PATCH 41/86] Update bucket index tests for the block-selectivity gate 92c39aa6 taught the planner to decline a bucket plan that would read most of the column's blocks, but I verified it against tests/ctable only and missed five tests in tests/ndarray that assert the index *is* used. Each builds a shuffled column, which is exactly the shape the gate now rejects: the matches land in every block, so there is nothing to prune. Measured on those very cases, declining is the right call -- index 1.65 ms against a 1.37 ms scan at a 0.98 block fraction, 3.65 ms against 3.03 ms at 0.94. The three tests whose subject is the bucket evaluator itself (lossy integer, lossy float, expression target) now build ordered data, so the plan is taken and evaluate_bucket_query / _process_bucket_chunk_batch / _coalesce_spans stay covered -- verified by counting calls. The threaded test asked for a 50% span, which reads over half the blocks; narrowed to 20%, still 4 chunks across the pool. test_random_field_index_matches_scan keeps its shuffled column, since that is its subject, and now asserts the bucket plan is declined while partial and full are unaffected. Co-Authored-By: Claude Opus 5 --- tests/ndarray/test_indexing.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/tests/ndarray/test_indexing.py b/tests/ndarray/test_indexing.py index 6aa54d91e..b1a6837b1 100644 --- a/tests/ndarray/test_indexing.py +++ b/tests/ndarray/test_indexing.py @@ -265,7 +265,10 @@ def test_random_field_index_matches_scan(kind): arr.create_index(field="id", kind=_public_kind(kind)) expr = blosc2.lazyexpr("(id >= 70_000) & (id < 71_200)", arr.fields).where(arr) - assert expr.will_use_index() is True + # A shuffled column spreads the matches over every block, so a bucket mask + # prunes nothing worth reading and the planner declines it in favour of the + # scan. partial and full produce exact positions and are unaffected. + assert expr.will_use_index() is (kind != "bucket") indexed = expr.compute()[:] scanned = expr.compute(_use_index=False)[:] @@ -354,11 +357,11 @@ def test_numeric_unsupported_dtype_fallback_matches_scan(): def test_bucket_lossy_integer_values_match_scan(): - rng = np.random.default_rng(2) dtype = np.dtype([("id", np.int64), ("payload", np.float32)]) data = np.zeros(180_000, dtype=dtype) + # Ordered, so the matches sit in few blocks and the bucket evaluator — the + # thing under test — is actually reached rather than declined for a scan. data["id"] = np.arange(-90_000, 90_000, dtype=np.int64) - rng.shuffle(data["id"]) arr = blosc2.asarray(data, chunks=(18_000,), blocks=(3_000,)) descriptor = arr.create_index(field="id", kind=blosc2.IndexKind.BUCKET, optlevel=0) @@ -375,11 +378,10 @@ def test_bucket_lossy_integer_values_match_scan(): def test_bucket_lossy_float_values_match_scan(): - rng = np.random.default_rng(3) dtype = np.dtype([("x", np.float64), ("payload", np.float32)]) data = np.zeros(160_000, dtype=dtype) + # Ordered for the same reason as the integer case above. data["x"] = np.linspace(-5000.0, 5000.0, data.shape[0], dtype=np.float64) - rng.shuffle(data["x"]) arr = blosc2.asarray(data, chunks=(16_000,), blocks=(4_000,)) descriptor = arr.create_index(field="x", kind=blosc2.IndexKind.BUCKET, optlevel=0) @@ -537,7 +539,10 @@ def test_bucket_threaded_downstream_order_matches_scan(monkeypatch): monkeypatch.setattr(indexing, "INDEX_QUERY_MIN_CHUNKS_PER_THREAD", 1) monkeypatch.setattr(blosc2, "nthreads", 4) - expr = blosc2.lazyexpr("(id >= 60_000) & (id < 180_000)", arr.fields).where(arr) + # 4 of the 20 chunks: enough to fan out over the thread pool, few enough + # blocks that the plan is worth taking. A 50% span reads over half the + # column's blocks, at which point the planner rightly prefers the scan. + expr = blosc2.lazyexpr("(id >= 60_000) & (id < 108_000)", arr.fields).where(arr) explanation = expr.explain() assert explanation["will_use_index"] is True @@ -545,7 +550,7 @@ def test_bucket_threaded_downstream_order_matches_scan(monkeypatch): indexed = expr.compute()[:] scanned = expr.compute(_use_index=False)[:] - expected = data[(data["id"] >= 60_000) & (data["id"] < 180_000)] + expected = data[(data["id"] >= 60_000) & (data["id"] < 108_000)] np.testing.assert_array_equal(indexed, scanned) np.testing.assert_array_equal(indexed, expected) @@ -1120,11 +1125,11 @@ def guarded_load(array, token, category, name, sidecar_path): @pytest.mark.parametrize("kind", ["bucket", "partial", "full"]) def test_expression_index_matches_scan(kind): - rng = np.random.default_rng(9) dtype = np.dtype([("x", np.int64), ("payload", np.int32)]) data = np.zeros(150_000, dtype=dtype) + # Ordered, so abs(x) puts the matches in two short runs rather than across + # every block, which is what lets the bucket plan be taken at all. data["x"] = np.arange(-75_000, 75_000, dtype=np.int64) - rng.shuffle(data["x"]) data["payload"] = np.arange(data.shape[0], dtype=np.int32) arr = blosc2.asarray(data, chunks=(15_000,), blocks=(3_000,)) From 3692673f687272a00f06e6885e86e713e3082a7e Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 28 Jul 2026 19:54:02 +0200 Subject: [PATCH 42/86] Give Utf8Array real comparison operators utf8_arr == "hello" returned a plain False: the class defined no comparison operators, so Python fell back to object identity. Silently wrong rather than an error, and it hit the one operation a user is most likely to reach for. All six comparisons now return a boolean mask. A scalar str is answered by equal_mask_span() / order_masks_span(), which were already there for the CTable predicate path and compare raw UTF-8 bytes without decoding a row; anything else (a list, an ndarray, another Utf8Array) is materialized and handed to NumPy. Results match NumPy exactly for multibyte values, empty strings and unflushed pending rows. __eq__ honours blosc2._disable_overloaded_equal the way NDArray does, and __hash__ is pinned to object.__hash__ so defining __eq__ does not silently make these containers unhashable. Co-Authored-By: Claude Opus 5 --- src/blosc2/_utf8_array.py | 63 +++++++++++++++++++++++++++++++++++++++ tests/ctable/test_utf8.py | 46 ++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index 0ae4fac81..7b166b680 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -33,6 +33,7 @@ from __future__ import annotations import itertools +import operator from typing import TYPE_CHECKING, Any import numpy as np @@ -65,6 +66,16 @@ # groupby._factorize_fixed_width_str). _HASH_MIX = np.uint64(0x9E3779B97F4A7C15) +# Fallback for comparisons against anything that is not a scalar str. +_COMPARE_OPS = { + "==": operator.eq, + "!=": operator.ne, + "<": operator.lt, + "<=": operator.le, + ">": operator.gt, + ">=": operator.ge, +} + def _factorize_byte_rows(mat: np.ndarray) -> tuple[np.ndarray, np.ndarray]: """Exact factorization of the rows of a ``(k, L)`` uint8 matrix. @@ -502,6 +513,58 @@ def __setitem__(self, index: int, value: Any) -> None: ) self._bytes_used_cache = new_used + # ------------------------------------------------------------------ + # Comparisons + # ------------------------------------------------------------------ + + def _compare(self, other: Any, op: str) -> np.ndarray: + """Element-wise comparison, returning a boolean mask. + + A scalar ``str`` is answered by the raw-byte scanners, which never + decode a row. Anything else (a list, an ndarray, another + :class:`Utf8Array`) is materialized and handed to NumPy. + """ + if isinstance(other, str): + n = len(self) + if op in ("==", "!="): + mask = self.equal_mask_span(other, 0, n) + return ~mask if op == "!=" else mask + lt, gt = self.order_masks_span(other, 0, n) + return {"<": lt, ">": gt, "<=": ~gt, ">=": ~lt}[op] + right = other[:] if isinstance(other, Utf8Array) else other + return _COMPARE_OPS[op](np.asarray(self[:]), right) + + # Identity hashing is kept: these objects were hashable before __eq__ was + # defined, and an element-wise __eq__ never returns a bool for the hash + # contract to apply to. + __hash__ = object.__hash__ + + def __eq__(self, other: Any, /): + import blosc2 + + if blosc2._disable_overloaded_equal: + return self is other + return self._compare(other, "==") + + def __ne__(self, other: Any, /): + import blosc2 + + if blosc2._disable_overloaded_equal: + return self is not other + return self._compare(other, "!=") + + def __lt__(self, other: Any, /): + return self._compare(other, "<") + + def __le__(self, other: Any, /): + return self._compare(other, "<=") + + def __gt__(self, other: Any, /): + return self._compare(other, ">") + + def __ge__(self, other: Any, /): + return self._compare(other, ">=") + # ------------------------------------------------------------------ # Properties mirroring the interface expected by CTable # ------------------------------------------------------------------ diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index eedd331b2..0106957cf 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -1570,3 +1570,49 @@ def test_ctable_utf8_scalar_predicate_on_view_and_after_delete(): def test_ctable_utf8_two_scalar_predicates_on_the_same_column(): t = make_table(["a", "b", "c", "d"]) assert list(t.where("(name > 'a') & (name < 'd')")["name"][:]) == ["b", "c"] + + +@pytest.mark.parametrize("op", ["==", "!=", "<", "<=", ">", ">="]) +def test_utf8_array_comparisons_match_numpy(op): + """Comparisons must be element-wise, not object identity. + + Without ``__eq__`` these fell through to identity, so ``arr == "hello"`` + was a plain ``False`` — silently wrong rather than an error. + """ + import operator + + values = ["hello", "world", "héllo", "abc", "", "hello"] + arr = blosc2.utf8_array(values) + ref = np.array(values, dtype=arr.dtype) + fn = getattr(operator, {"==": "eq", "!=": "ne", "<": "lt", "<=": "le", ">": "gt", ">=": "ge"}[op]) + + for probe in ("hello", "héllo", "", "zzz"): + got = fn(arr, probe) + assert isinstance(got, np.ndarray) + assert got.dtype == np.bool_ + np.testing.assert_array_equal(got, fn(ref, probe)) + + +def test_utf8_array_comparison_against_array_likes(): + values = ["a", "bb", "ccc"] + arr = blosc2.utf8_array(values) + ref = np.array(values, dtype=arr.dtype) + + np.testing.assert_array_equal(arr == values, np.ones(3, dtype=bool)) + np.testing.assert_array_equal(arr == ref, np.ones(3, dtype=bool)) + np.testing.assert_array_equal(arr == blosc2.utf8_array(values), np.ones(3, dtype=bool)) + np.testing.assert_array_equal(arr != blosc2.utf8_array(["a", "x", "ccc"]), [False, True, False]) + + +def test_utf8_array_comparison_edge_cases(): + # Unflushed pending rows take part in the comparison. + arr = blosc2.utf8_array(["a"]) + arr.append("b") + np.testing.assert_array_equal(arr == "b", [False, True]) + + # Empty array yields an empty mask rather than raising. + empty = blosc2.utf8_array([]) + assert (empty == "x").shape == (0,) + + # Defining __eq__ must not have made the container unhashable. + assert isinstance(hash(arr), int) From 174eb7ce4a98af7dae9db0ba02a644855920c1f5 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 28 Jul 2026 19:54:10 +0200 Subject: [PATCH 43/86] Record the Utf8Array comparison fix in the assessment Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 20ab2e3aa..e5d89dfee 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -34,7 +34,7 @@ because two of the conclusions below originally rested on it. | nested (dotted) leaf in expr | ✓ | ✓ | ✗ NotImpl | ✗ | ✗ | | **Bare container (no CTable)** | | | | | | | `lazyexpr(expr, {a: col})` | ✓ NDArray | ✓ | ⚠ returns `` | `string` / `large_binary` | | save + reopen | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -45,7 +45,10 @@ output with `dtype=StringDType()`, which `NDArray.dtype`'s `ast.literal_eval` ro parse (`blosc2_ext.pyx:3818`). ² Correct values down the wrong path: it never reaches miniexpr, ignores `_UTF8_EXPR_BUDGET`, and loses the utf8 container. -³ No `__eq__` on `Utf8Array`, so this is object identity — **silently wrong**, not an error. +³ Fixed in `3692673f` — was a plain `False` (object identity, silently wrong) because `Utf8Array` +defined no comparison operators. All six now return a boolean mask, answering a scalar `str` with +the existing raw-byte scanners so no row is decoded. `dictionary` and `vlstring` are still +identity-compared. ### Measured cost — 200 k rows of free text, max 37 chars @@ -266,9 +269,10 @@ utf8 without an index, and the parity plan spends a week doing so. Given that th is already proven in-tree for dictionary and costs about as much as G2 alone, the priority order inverts: -1. **Fix what is wrong, not merely absent** — `Utf8Array.__eq__` returning `False` (make it work or - make it raise; ~1 h) and the bare-array `lazyexpr` numpy fallback (G4). Silent-wrong results, - and they bite regardless of which rule is chosen. +1. ~~**Fix what is wrong, not merely absent** — `Utf8Array.__eq__` returning `False`~~ — **done**, + `3692673f`; `==`, `!=`, `<`, `<=`, `>`, `>=` all return boolean masks now. The bare-array + `lazyexpr` numpy fallback (G4) is the remaining silent-wrong result and still worth fixing + whichever rule is chosen. 2. **Publish the conversion pair.** `Utf8Array.astype(" Date: Tue, 28 Jul 2026 20:49:17 +0200 Subject: [PATCH 44/86] Route bare Utf8Array expressions through the span driver blosc2.lazyexpr("'x=' + a", {"a": utf8_arr}).compute() returned correct values down entirely the wrong path. A Utf8Array only duck-types as an operand: LazyExpr wrapped it in a SimpleProxy, which converts anything without .shape to NumPy, so the column was widened to a fixed --- src/blosc2/_utf8_array.py | 188 ++++++++++++++++++++++++++++++++++++++ src/blosc2/ctable.py | 116 +++++------------------ src/blosc2/lazyexpr.py | 17 ++++ tests/ctable/test_utf8.py | 69 ++++++++++++++ 4 files changed, 295 insertions(+), 95 deletions(-) diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index 7b166b680..9f49ff0b6 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -66,6 +66,194 @@ # groupby._factorize_fixed_width_str). _HASH_MIX = np.uint64(0x9E3779B97F4A7C15) +#: Nominal row span for evaluating an expression over utf8 operands. +UTF8_EXPR_SPAN = 65536 + +#: Byte ceiling for one span's fixed-width `` np.dtype: + """Fixed-width ``U`` dtype wide enough for every value in *span*. + + The width is span-local and data-dependent, whereas miniexpr bakes output + widths in at compile time, so round it up to a power of two: a column then + costs a handful of distinct compilations instead of one per span. + """ + longest = max((len(s) for s in span), default=0) + return np.dtype(f" None: + self.expression = expression + self.operands = dict(operands) + self._ne_args = ne_args + self._utf8 = {k: v for k, v in self.operands.items() if isinstance(v, Utf8Array)} + if not self._utf8: + raise ValueError("Utf8LazyExpr needs at least one Utf8Array operand") + + def __len__(self) -> int: + return min(len(v) for v in self._utf8.values()) + + @property + def shape(self) -> tuple[int, ...]: + return (len(self),) + + def compute(self, item=(), **kwargs): + """Evaluate the whole expression. + + Returns a :class:`Utf8Array` for a string result and a NumPy array for + a boolean or numeric one. ``strict_miniexpr=True`` asserts that + evaluation really did reach miniexpr rather than a NumPy fallback. + """ + if item not in ((), slice(None), Ellipsis): + raise NotImplementedError( + "expressions over a bare Utf8Array evaluate whole-array only; " + "call compute() and slice the result" + ) + strict = kwargs.pop("strict_miniexpr", False) + if kwargs: + raise TypeError(f"unexpected keyword arguments: {sorted(kwargs)}") + return utf8_span_eval( + self.expression, + {k: v for k, v in self.operands.items() if k not in self._utf8}, + self._utf8, + {k: v.spec.null_value for k, v in self._utf8.items()}, + len(self), + strict=strict, + # Read at call time, not bound as a default, so both are tunable. + span_rows=UTF8_EXPR_SPAN, + budget=UTF8_EXPR_BUDGET, + ) + + def __getitem__(self, item): + result = self.compute() + return result[item] + + def __str__(self) -> str: + return self.expression + + def __repr__(self) -> str: + return f"Utf8LazyExpr({self.expression!r}, shape={self.shape})" + + # Fallback for comparisons against anything that is not a scalar str. _COMPARE_OPS = { "==": operator.eq, diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 2cd091158..e7e8f1238 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -541,17 +541,6 @@ def __repr__(self) -> str: # --------------------------------------------------------------------------- -def _utf8_span_dtype(span: np.ndarray) -> np.dtype: - """Fixed-width ``U`` dtype wide enough for every value in *span*. - - The width is span-local and data-dependent, whereas miniexpr bakes output - widths in at compile time, so round it up to a power of two: a column then - costs a handful of distinct compilations instead of one per span. - """ - longest = max((len(s) for s in span), default=0) - return np.dtype(f" int: """Translate a logical (valid-row) index into a physical array index. @@ -12721,20 +12710,10 @@ def _guard_varlen_scalar_expression(self, expr: str, *, allow_utf8: bool = False _UTF8_EXPR_BUDGET = 64 << 20 def _utf8_spans(self, arrays: dict, n_logical: int): - """Yield ``(start, stop)`` row spans to materialize, longest value first. + """Row spans to materialize; see :func:`~blosc2._utf8_array.utf8_spans`.""" + from blosc2._utf8_array import utf8_spans - Splits the nominal ``_UTF8_EXPR_SPAN`` further whenever the widest - value in it would push the `` list[str]: """utf8 column names referenced by *expr*, in schema order. @@ -12834,77 +12813,24 @@ def _lazyexpr_over_cols(self, expr: str, operands: dict, utf8_names: list[str]): return self._utf8_span_eval(expr, operands, utf8_names) def _utf8_span_eval(self, expr: str, operands: dict, utf8_names: list[str], *, strict: bool = False): - """Evaluate *expr* in row spans, materializing utf8 operands per span. - - Returns a result of the table's *physical* length (the coordinate - system every other predicate here uses); rows past the utf8 columns' - logical length keep the zero value of the result dtype. A bool or - numeric result is a NumPy array; a **string** result is a - :class:`Utf8Array`, following the contagion rule -- a string-returning - expression with a utf8 operand stays variable-width rather than - widening every row to miniexpr's compile-time bound. It is built by - extending span by span, so only one span's `` bool: col = self[name] diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 9ac9c2217..81d57ec9c 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -5336,6 +5336,23 @@ def lazyexpr( [ 5.515625 8.25 11.765625] [16.0625 21.140625 27. ]] """ + if operands is not None and isinstance(expression, str): + # A Utf8Array is variable-width, so it cannot be an expression operand. + # It only duck-types as one: LazyExpr would wrap it in a SimpleProxy, + # which converts it to a fixed-width 1)", {"a": arr, "n": nums}) + np.testing.assert_array_equal(mixed.compute(strict_miniexpr=True), [False, False, True]) + + +@pytest.mark.parametrize(("span_rows", "budget"), [(7, 64 << 20), (65536, 512)]) +def test_bare_utf8_array_expression_splits_spans(span_rows, budget, monkeypatch): + """Both the row-span and the byte-budget splits must hold over a bare array.""" + from blosc2 import _utf8_array + + values = [f"row-{i}" for i in range(50)] + arr = blosc2.utf8_array(values) + + spans = [] + original = _utf8_array.utf8_spans + monkeypatch.setattr( + _utf8_array, + "utf8_spans", + lambda a, n, s, b: [spans.append(x) or x for x in original(a, n, s, b)], + ) + monkeypatch.setattr(_utf8_array, "UTF8_EXPR_SPAN", span_rows) + monkeypatch.setattr(_utf8_array, "UTF8_EXPR_BUDGET", budget) + + result = blosc2.lazyexpr("'x=' + a", {"a": arr}).compute(strict_miniexpr=True) + assert len(spans) > 1, f"expected a split, got {spans}" + assert list(result[:]) == ["x=" + v for v in values] + + +def test_bare_utf8_array_expression_rejects_unsupported_forms(): + arr = blosc2.utf8_array(["a", "b"]) + lazy = blosc2.lazyexpr("upper(a)", {"a": arr}) + + assert lazy.shape == (2,) + assert len(lazy) == 2 + assert list(lazy[0:1]) == ["A"] + + with pytest.raises(NotImplementedError, match="whole-array only"): + lazy.compute(item=slice(0, 1)) + with pytest.raises(NotImplementedError, match="not supported"): + blosc2.lazyexpr("upper(a)", {"a": arr}, where=(arr, arr)) From d04badce7762c22f038ab4f87a9c25bbc2e24329 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 28 Jul 2026 20:49:42 +0200 Subject: [PATCH 45/86] Record the G4 fix in the assessment Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index e5d89dfee..b151f9f89 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -33,7 +33,7 @@ because two of the conclusions below originally rested on it. | `t.apply(dsl_kernel)` / `lazyudf` | ✓ | ✓ | **✗ ValueError** ¹ | ✗ | ✗ RuntimeError | | nested (dotted) leaf in expr | ✓ | ✓ | ✗ NotImpl | ✗ | ✗ | | **Bare container (no CTable)** | | | | | | -| `lazyexpr(expr, {a: col})` | ✓ NDArray | ✓ | ⚠ returns `` | `string` / `large_binary` | @@ -43,8 +43,10 @@ because two of the conclusions below originally rested on it. ¹ `ValueError: malformed node or string … StringDType()` — `lazyudf` tries to allocate an NDArray output with `dtype=StringDType()`, which `NDArray.dtype`'s `ast.literal_eval` round-trip cannot parse (`blosc2_ext.pyx:3818`). -² Correct values down the wrong path: it never reaches miniexpr, ignores `_UTF8_EXPR_BUDGET`, and -loses the utf8 container. +² Fixed in `0b486b07` — was correct values down the wrong path: a `SimpleProxy` widened the column +to a fixed ``, `>=` all return boolean masks now. The bare-array - `lazyexpr` numpy fallback (G4) is the remaining silent-wrong result and still worth fixing - whichever rule is chosen. +1. ~~**Fix what is wrong, not merely absent**~~ — **done**. `Utf8Array` comparisons (`3692673f`) + and the bare-array `lazyexpr` fallback (G4, `0b486b07`). Both were silent-wrong results, and + neither depended on which rule is chosen below. **No known silently-wrong utf8 path remains.** 2. **Publish the conversion pair.** `Utf8Array.astype(" Date: Tue, 28 Jul 2026 21:21:53 +0200 Subject: [PATCH 46/86] Give dictionary and varlen scalar columns element-wise comparisons Same bug 3692673f fixed for Utf8Array, still live in the other two string containers: neither defined comparison operators, so column == "value" fell through to object identity and returned a plain False. Silently wrong rather than an error, and it survived because the CTable layer answers == through its own predicate path and never asks the container. DictionaryColumn compares codes -- the literal is mapped to its code once and the int32 codes array is compared whole, so no row is decoded, and a value absent from the dictionary matches nothing instead of raising. Null slots hold null_code and so never match. _ScalarVarLenArray rows are arbitrary msgpack payloads, so its comparison is handed to NumPy over the decoded values. Both honour blosc2._disable_overloaded_equal and pin __hash__ to object.__hash__, so defining __eq__ does not silently make them unhashable. Co-Authored-By: Claude Opus 5 --- src/blosc2/dictionary_column.py | 37 ++++++++++++++++++++++++++ src/blosc2/scalar_array.py | 28 +++++++++++++++++++ tests/ctable/test_dictionary_column.py | 24 +++++++++++++++++ tests/test_objectarray.py | 15 +++++++++++ 4 files changed, 104 insertions(+) diff --git a/src/blosc2/dictionary_column.py b/src/blosc2/dictionary_column.py index f9b2bfc67..dc57d19eb 100644 --- a/src/blosc2/dictionary_column.py +++ b/src/blosc2/dictionary_column.py @@ -247,6 +247,43 @@ def __getitem__(self, key) -> str | None | list: return [self.decode(int(codes_arr))] raise TypeError(f"DictionaryColumn indices must be int, slice, or array; got {type(key)!r}") + # Identity hashing is kept: these objects were hashable before __eq__ was + # defined, and an element-wise __eq__ never returns a bool for the hash + # contract to apply to. + __hash__ = object.__hash__ + + def __eq__(self, other): + """Element-wise equality mask, over the same slots ``self[:]`` exposes. + + Without this the comparison fell through to object identity and + ``column == "value"`` was a plain ``False`` — silently wrong. A scalar + string is answered by comparing *codes*, so no row is decoded; null + slots hold ``null_code`` and so never match. + """ + import blosc2 + + if blosc2._disable_overloaded_equal: + return self is other + return self._equality_mask(other, invert=False) + + def __ne__(self, other): + import blosc2 + + if blosc2._disable_overloaded_equal: + return self is not other + return self._equality_mask(other, invert=True) + + def _equality_mask(self, other, *, invert: bool): + codes = np.asarray(self._codes[:], dtype=np.int32) + if isinstance(other, str): + self._ensure_cache() + assert self._value_to_code is not None + code = self._value_to_code.get(other) + mask = np.zeros(len(codes), dtype=bool) if code is None else codes == code + else: + mask = np.asarray(self[:], dtype=object) == other + return ~mask if invert else mask + def __setitem__(self, key, value) -> None: """Encode *value* (str/None or list thereof) and write the code(s).""" if isinstance(key, (int, np.integer)): diff --git a/src/blosc2/scalar_array.py b/src/blosc2/scalar_array.py index 5865f5c01..9220e2cce 100644 --- a/src/blosc2/scalar_array.py +++ b/src/blosc2/scalar_array.py @@ -24,6 +24,8 @@ from collections import defaultdict from typing import TYPE_CHECKING, Any +import numpy as np + if TYPE_CHECKING: from collections.abc import Iterable, Iterator @@ -266,6 +268,32 @@ def __len__(self) -> int: def __iter__(self) -> Iterator[Any]: yield from self[:] + # Identity hashing is kept: these objects were hashable before __eq__ was + # defined, and an element-wise __eq__ never returns a bool for the hash + # contract to apply to. + __hash__ = object.__hash__ + + def __eq__(self, other): + """Element-wise equality mask over the stored rows. + + Without this the comparison fell through to object identity and + ``column == "value"`` was a plain ``False`` — silently wrong. Rows hold + arbitrary msgpack payloads, so the comparison is handed to NumPy over + the decoded values. + """ + import blosc2 + + if blosc2._disable_overloaded_equal: + return self is other + return np.asarray(self[:], dtype=object) == other + + def __ne__(self, other): + import blosc2 + + if blosc2._disable_overloaded_equal: + return self is not other + return np.asarray(self[:], dtype=object) != other + def __getitem__(self, index: int | slice | list | tuple) -> Any | list[Any]: if isinstance(index, int): n = len(self) diff --git a/tests/ctable/test_dictionary_column.py b/tests/ctable/test_dictionary_column.py index 0c0523347..4d5a95283 100644 --- a/tests/ctable/test_dictionary_column.py +++ b/tests/ctable/test_dictionary_column.py @@ -576,3 +576,27 @@ def counting_getitem(self, key): if __name__ == "__main__": pytest.main(["-v", __file__]) + + +def test_dictionary_column_comparisons_are_elementwise(): + """``column == value`` must not fall through to object identity. + + It used to return a plain ``False`` — silently wrong rather than an error. + """ + import numpy as np + + @dataclass + class Row: + c: str = blosc2.field(blosc2.dictionary()) + + t = CTable(Row) + t.extend({"c": ["hello", "world", "hello"]}, validate=False) + t._flush_varlen_columns() + col = t._cols["c"] + + np.testing.assert_array_equal((col == "hello")[:3], [True, False, True]) + np.testing.assert_array_equal((col != "hello")[:3], [False, True, False]) + # A value absent from the dictionary matches nothing rather than raising. + np.testing.assert_array_equal((col == "absent")[:3], [False, False, False]) + # Defining __eq__ must not have made the container unhashable. + assert isinstance(hash(col), int) diff --git a/tests/test_objectarray.py b/tests/test_objectarray.py index a3ae8c0ce..5006b67c4 100644 --- a/tests/test_objectarray.py +++ b/tests/test_objectarray.py @@ -535,3 +535,18 @@ def test_objectarray_delete_negative_step_slice(): oarr2.extend(range(5)) del oarr2[::-1] assert len(oarr2) == 0 + + +def test_varlen_scalar_column_comparisons_are_elementwise(): + """``column == value`` must not fall through to object identity.""" + from dataclasses import make_dataclass + + row_cls = make_dataclass("Row", [("c", str, blosc2.field(blosc2.vlstring()))]) + t = blosc2.CTable(row_cls) + t.extend({"c": ["hello", "world", "hello"]}, validate=False) + t._flush_varlen_columns() + col = t._cols["c"] + + np.testing.assert_array_equal(col == "hello", [True, False, True]) + np.testing.assert_array_equal(col != "hello", [False, True, False]) + assert isinstance(hash(col), int) From 9a9c17636485957d26b27f7a1d306f4bc096f350 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Tue, 28 Jul 2026 21:22:26 +0200 Subject: [PATCH 47/86] Record the dictionary/vlstring comparison fix in the assessment Also note why lazyexpr over a bare DictionaryColumn returns a capacity-padded result and why that is being left alone. Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index b151f9f89..41f9290d9 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -33,8 +33,8 @@ because two of the conclusions below originally rested on it. | `t.apply(dsl_kernel)` / `lazyudf` | ✓ | ✓ | **✗ ValueError** ¹ | ✗ | ✗ RuntimeError | | nested (dotted) leaf in expr | ✓ | ✓ | ✗ NotImpl | ✗ | ✗ | | **Bare container (no CTable)** | | | | | | -| `lazyexpr(expr, {a: col})` | ✓ NDArray | ✓ | ✓ span driver, returns `Utf8Array` ² | ⚠ | ⚠ | -| `col == "scalar"` | ✓ LazyExpr | ✓ LazyExpr | ✓ bool mask ³ | ⚠ `False` | ⚠ `False` | +| `lazyexpr(expr, {a: col})` | ✓ NDArray | ✓ | ✓ span driver, returns `Utf8Array` ² | ⚠ padded ⁶ | ⚠ numpy | +| `col == "scalar"` | ✓ LazyExpr | ✓ LazyExpr | ✓ bool mask ³ | ✓ bool mask ³ | ✓ bool mask ³ | | **Interop** | | | | | | | `to_arrow` | `string` | `large_binary` | `large_string` | `dictionary<…>` | `string` / `large_binary` | | save + reopen | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -47,10 +47,12 @@ parse (`blosc2_ext.pyx:3818`). to a fixed ` Date: Wed, 29 Jul 2026 07:56:34 +0200 Subject: [PATCH 48/86] Support create_index on utf8 columns via alphabetical ranks create_index raised NotImplementedError for utf8, leaving it the only string flavour with no index at all: sorting 1M rows meant a full lexsort every time. Sorting by alphabetical rank is sorting by decoded string, so an int32 rank column drives the existing numeric index machinery unchanged -- the trick _DictRankWrapper already plays for dictionary columns. utf8 has no stored code array to wrap lazily, so _utf8_rank_arrays() factorizes the column (the factorizer hashes raw bytes and only ever decodes the distinct values) and materializes the ranks at 4 B/row. The null sentinel is just another vocabulary entry, so it is given the largest rank and nulls sort last, matching both the dictionary index and _build_lex_keys. 1M rows, cardinality 20k, persistent: sorted_slice top-100 458.5 ms -> 43.2 ms sort_by(view)[:100] 424.2 ms -> 7.2 ms index build 277 ms ( --- src/blosc2/ctable.py | 26 ++++++++++++- src/blosc2/ctable_indexing.py | 72 +++++++++++++++++++++++++++++++---- src/blosc2/schema.py | 11 ++++++ tests/ctable/test_utf8.py | 69 +++++++++++++++++++++++++++++++-- 4 files changed, 165 insertions(+), 13 deletions(-) diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index e7e8f1238..c2b747c73 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -4087,6 +4087,21 @@ def _dict_rank_index_stale(self, name: str, dict_rank_meta: dict) -> bool: return True return _dict_rank_hash(dictionary) != dict_rank_meta.get("dict_hash") + def _utf8_rank_index_stale(self, name: str, utf8_rank_meta: dict) -> bool: + """True if a utf8-rank FULL index no longer matches the live column. + + The index encodes alphabetical ranks frozen at build time, so a value + appended ahead of existing ones invalidates every rank, not just the new + rows'. Checked with O(1) signals — re-deriving the vocabulary would mean + factorizing the column on every query. + """ + col = self._root_table._cols.get(name) + if col is None: + return True + return len(col) != utf8_rank_meta.get("n_rows") or int(col._bytes_used) != utf8_rank_meta.get( + "nbytes" + ) + @staticmethod def _is_ndarray_column(col: CompiledColumn) -> bool: return isinstance(col.spec, NDArraySpec) @@ -11147,11 +11162,14 @@ def _sorted_positions_from_full_index(self, name: str, ascending: bool) -> np.nd descriptor = None else: dict_rank_meta = descriptor.get("full", {}).get("dict_rank") + utf8_rank_meta = descriptor.get("full", {}).get("utf8_rank") if dict_rank_meta is not None: if self._dict_rank_index_stale(name, dict_rank_meta): descriptor = None # ranks no longer match dictionary → lexsort else: is_dict_rank = True + elif utf8_rank_meta is not None and self._utf8_rank_index_stale(name, utf8_rank_meta): + descriptor = None # ranks no longer match the column → lexsort elif name in root._computed_cols: cc = root._computed_cols[name] for _lookup_key, candidate in catalog.items(): @@ -11464,12 +11482,18 @@ def _sorted_slice_positions(self, name: str, ascending: bool, key: slice) -> np. col_info = self._schema.columns_by_name.get(name) null_value = getattr(col_info.spec, "null_value", None) if col_info is not None else None - # Dict-rank index: use null_rank (int32) as sentinel for null-block location. + # Rank index: the sidecar holds int32 ranks, so the null block is located + # by null_rank, not by the column's own sentinel (a string, for utf8). dict_rank = full.get("dict_rank") if dict_rank is not None: if self._dict_rank_index_stale(name, dict_rank): return None # ranks no longer match dictionary → lexsort null_value = dict_rank["null_rank"] + utf8_rank = full.get("utf8_rank") + if utf8_rank is not None: + if self._utf8_rank_index_stale(name, utf8_rank): + return None # ranks no longer match the column → lexsort + null_value = utf8_rank["null_rank"] if null_value is not None else None # Numeric / NaN / string sentinels keep the null rows in one contiguous block # once sorted; other non-numeric sentinels (e.g. object) would need a # different locator. diff --git a/src/blosc2/ctable_indexing.py b/src/blosc2/ctable_indexing.py index 7ec5f7a37..7d55e736f 100644 --- a/src/blosc2/ctable_indexing.py +++ b/src/blosc2/ctable_indexing.py @@ -74,6 +74,55 @@ def _dict_rank_hash(dictionary) -> str: return h.hexdigest() +#: Rows factorized per pass when building a utf8 rank index. Bounds the +#: transient code buffer without changing the result. +_UTF8_RANK_SPAN = 1 << 20 + + +def _utf8_rank_arrays(col, n_live: int, null_value: str | None): + """Alphabetical rank per row for a utf8 column, plus its staleness metadata. + + Sorting by rank is sorting by decoded string, so an ``int32`` rank column + can drive the whole numeric index machinery unchanged — the same trick + :class:`_DictRankWrapper` plays for dictionary columns. Unlike a dictionary + there is no stored code array, so the column is factorized here; the + factorizer hashes raw bytes and only ever decodes the distinct values. + + Null rows carry a sentinel *string*, so the sentinel is just another + vocabulary entry; it is given the largest rank so nulls sort last, matching + both the dictionary index and ``_build_lex_keys``. + """ + fact = col.factorizer() + codes = np.empty(n_live, dtype=np.int64) + for start in range(0, n_live, _UTF8_RANK_SPAN): + stop = min(start + _UTF8_RANK_SPAN, n_live) + codes[start:stop] = fact.codes_for_span(start, stop) + uniques = fact.uniques() + n_entries = len(uniques) + + is_null = uniques == null_value if null_value is not None else np.zeros(n_entries, dtype=bool) + non_null = np.flatnonzero(~is_null) + order = non_null[np.argsort(uniques[non_null], kind="stable")] + code_to_rank = np.empty(max(n_entries, 1), dtype=np.int32) + code_to_rank[order] = np.arange(len(order), dtype=np.int32) + null_rank = np.int32(len(order)) + if is_null.any(): + code_to_rank[is_null] = null_rank + + ranks = code_to_rank[codes] if n_entries else np.zeros(n_live, dtype=np.int32) + # Staleness signals must be O(1) to check: re-deriving the vocabulary would + # mean factorizing the column again on every query. Any write already marks + # every index stale, so these only have to catch a rebuilt-but-changed + # column, for which row count plus blob size is enough. + meta = { + "null_rank": int(null_rank), + "vocab_len": int(n_entries), + "n_rows": int(n_live), + "nbytes": int(col._bytes_used), + } + return ranks.astype(np.int32, copy=False), meta + + class _DictRankWrapper: """Wrap a dictionary column's codes NDArray, translating codes to alphabetical ranks on read. @@ -750,12 +799,6 @@ def create_index( # noqa: C901 ) if isinstance(self._schema.columns_by_name[col_name].spec, ListSpec): raise ValueError(f"Cannot create an index on list column {col_name!r} in V1.") - if isinstance(self._schema.columns_by_name[col_name].spec, Utf8Spec): - raise NotImplementedError( - f"Cannot create an index on variable-length utf8 column {col_name!r}: " - "indexing for utf8 columns is not supported yet. " - "Use a fixed-width string(max_length=N) column if you need an index." - ) if isinstance( self._schema.columns_by_name[col_name].spec, (VLStringSpec, VLBytesSpec, StructSpec, ObjectSpec) ): @@ -763,6 +806,16 @@ def create_index( # noqa: C901 f"Cannot create an index on variable-length scalar column {col_name!r}: " "indexing for vlstring/vlbytes/struct/object columns is not supported yet." ) + # utf8 columns: index the alphabetical rank of each row's value. There is + # no stored code array to wrap lazily, so the ranks are materialized here + # (int32, 4 B/row) and handed to the builder as an ordinary array. + is_utf8 = isinstance(self._schema.columns_by_name[col_name].spec, Utf8Spec) + utf8_rank_meta = None + if is_utf8: + n_live = self._n_rows if self._n_rows is not None else len(self._valid_rows) + ranks_arr, utf8_rank_meta = _utf8_rank_arrays(col_arr, n_live, self[col_name].null_value) + col_arr = blosc2.asarray(ranks_arr) + # Dictionary columns: index by alphabetical rank instead of insertion-order codes. is_dictionary = isinstance(self._schema.columns_by_name[col_name].spec, DictionarySpec) dict_rank_meta = None @@ -823,11 +876,14 @@ def create_index( # noqa: C901 ) store = _IN_MEMORY_INDEXES[id(col_arr)] descriptor = _copy_descriptor(store["indexes"]["__self__"]) - if dict_rank_meta is not None: + if dict_rank_meta is not None or utf8_rank_meta is not None: full = descriptor.setdefault("full", {}) if full is None: full = descriptor["full"] = {} - full["dict_rank"] = dict_rank_meta + if dict_rank_meta is not None: + full["dict_rank"] = dict_rank_meta + else: + full["utf8_rank"] = utf8_rank_meta value_epoch, _ = self._storage.get_epoch_counters() descriptor["built_value_epoch"] = value_epoch diff --git a/src/blosc2/schema.py b/src/blosc2/schema.py index 79b9007c5..5d8530d67 100644 --- a/src/blosc2/schema.py +++ b/src/blosc2/schema.py @@ -622,6 +622,17 @@ class Utf8Spec(SchemaSpec): def __init__(self, *, nullable: _builtin_bool = False, null_value: str | None = None): if null_value is not None and not isinstance(null_value, str): raise TypeError(f"utf8 null_value must be str, got {type(null_value).__name__!r}") + if null_value == "\x00": + # NumPy 2.4 compares a lone NUL against StringDType as no-match: + # np.array(["\x00"], dtype=StringDType()) == "\x00" is False, while + # "\x00x" and "a\x00b" both compare correctly. Every null mask here + # is such a comparison, so this sentinel would silently stop marking + # anything as null. Reject it rather than mis-handle it. + raise ValueError( + "utf8 null_value cannot be a single NUL character: NumPy does not " + "match it against StringDType arrays, so nulls would go undetected. " + "Use a longer sentinel (the default is '__BLOSC2_NULL__')." + ) self.nullable = nullable or null_value is not None self.null_value = _normalize_scalar_value(null_value) diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index 9d3286899..8499a829d 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -1172,10 +1172,24 @@ def test_ctable_utf8_sort_non_ascii(): # --------------------------------------------------------------------------- -def test_ctable_utf8_create_index_raises_clearly(): - t = make_table() - with pytest.raises(NotImplementedError, match="utf8"): - t.create_index(col_name="name") +def test_ctable_utf8_create_index_builds_a_rank_index(): + """utf8 is indexed by the alphabetical rank of each row's value. + + Sorting by rank is sorting by decoded string, so an int32 rank column drives + the existing numeric index machinery unchanged. + """ + values = ["pear", "apple", "café", "banana", "apple"] + t = make_table(values) + index = t.create_index(col_name="name", kind="full") + + assert index.kind == "full" + meta = t._get_index_catalog()["name"]["full"]["utf8_rank"] + assert meta["vocab_len"] == len(set(values)) + assert meta["n_rows"] == len(values) + + # Ordering through the index must match a plain sort. + assert list(t.sort_by("name", view=True)["name"][:]) == sorted(values) + assert list(t.sorted_slice("name", slice(0, 2))["name"][:]) == sorted(values)[:2] def test_ctable_utf8_arrow_export_large_string(): @@ -1685,3 +1699,50 @@ def test_bare_utf8_array_expression_rejects_unsupported_forms(): lazy.compute(item=slice(0, 1)) with pytest.raises(NotImplementedError, match="not supported"): blosc2.lazyexpr("upper(a)", {"a": arr}, where=(arr, arr)) + + +def test_ctable_utf8_index_survives_reopen_and_orders_nulls_last(tmp_path): + """A persisted utf8 rank index must reopen and keep nulls at the end.""" + from dataclasses import make_dataclass + + path = str(tmp_path / "utf8_index.b2t") + row_cls = make_dataclass("Row", [("name", str, blosc2.field(blosc2.utf8(nullable=True)))]) + values = ["pear", "apple", None, "banana"] + t = blosc2.CTable(row_cls, urlpath=path, mode="w") + t.extend({"name": values}, validate=False) + t._flush_varlen_columns() + t.create_index("name", kind="full") + del t + + reopened = blosc2.open(path) + assert reopened._get_index_catalog()["name"]["full"]["utf8_rank"]["null_rank"] == 3 + ordered = list(reopened.sort_by("name", view=True)["name"][:]) + assert ordered[:3] == ["apple", "banana", "pear"] + assert ordered[3] == reopened["name"].null_value # the null sentinel sorts last + + +def test_ctable_utf8_index_goes_stale_when_the_column_changes(): + """Appending a value ahead of existing ones invalidates every rank.""" + t = make_table(["pear", "banana"]) + t.create_index("name", kind="full") + meta = t._get_index_catalog()["name"]["full"]["utf8_rank"] + assert not t._utf8_rank_index_stale("name", meta) + + t.append({"name": "apple", "x": 2}) + t._flush_varlen_columns() + assert t._utf8_rank_index_stale("name", meta) + # The answer stays correct via the lexsort fallback. + assert list(t.sort_by("name", view=True)["name"][:]) == ["apple", "banana", "pear"] + + +def test_utf8_rejects_a_lone_nul_null_value(): + """NumPy will not match a lone NUL against StringDType, so nulls would vanish.""" + import numpy as np + + probe = np.array(["\x00"], dtype=np.dtypes.StringDType()) + assert not (probe == "\x00")[0], "numpy started matching lone NUL; the guard can go" + + with pytest.raises(ValueError, match="NUL"): + blosc2.utf8(null_value="\x00") + # A NUL that is not the whole string is fine — numpy matches those. + assert blosc2.utf8(null_value="\x00x").null_value == "\x00x" From 3fac5267089cd1250c160fb2af130e266532dd44 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 07:56:59 +0200 Subject: [PATCH 49/86] Record the utf8 rank index in the assessment Also correct the earlier claim that a rank index could not serve equality: the dictionary measurement behind it was a wiring gap (the planner is never consulted), not a limit of rank indexes. Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 65 +++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 41f9290d9..dab12f4ee 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -26,7 +26,7 @@ because two of the conclusions below originally rested on it. | `sum(where=…)` | ✓ | ✓ | ✓ | ✗ | ✗ | | `sort_by` | ✓ | ✓ | ✓ | ✓ | ✗ TypeError | | `group_by` | ✓ | ✓ | ✓ | ✓ | ✗ | -| `create_index` | ✓ all 5 kinds | ✓ all 5 kinds | **✗ NotImpl (all kinds)** | ✓ rank, ordering only | ✗ | +| `create_index` | ✓ all 5 kinds | ✓ all 5 kinds | ✓ rank, ordering only ⁷ | ✓ rank, ordering only | ✗ | | **Compute (string-returning)** | | | | | | | `add_computed_column("'x='+c")` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | | `assign(new=…)` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | @@ -71,8 +71,8 @@ caching the code→value map that `_ensure_cache()` was already building the for `create_index` is the one place where the flavours are not merely *slower* or *less convenient* than each other — they are in different performance classes. All five index kinds -(`SUMMARY`/`BUCKET`/`PARTIAL`/`FULL`/`OPSI`) build on `string()` and `bytes()`; **all five raise -`NotImplementedError` on `utf8()`**, from a single guard at `ctable_indexing.py:753`. +(`SUMMARY`/`BUCKET`/`PARTIAL`/`FULL`/`OPSI`) build on `string()`, `bytes()` and — since `b1bbc54e` — +`utf8()`. What follows was written when utf8 had no index at all; the ⁷ note records what changed. ### What a FULL index buys on `` and ranges, + because rank order *is* lexicographic order. - ✗ `startswith` — nothing indexes prefixes on any flavour. - ⚠ staleness: ranks shift when new values arrive. Dictionary handles this with a stored `dict_hash` (`_dict_rank_hash`) and falls back to lexsort when stale; utf8 would need the same, and a utf8 column is much more likely to gain new values than a category column is — which means the fallback would fire often, and the fallback is a full lexsort. -Rough cost: **2–3 days**, most of it staleness and the persistent-sidecar round trip, not the rank -computation. Comparable to G2 in the parity plan, and it buys something G2 does not — but it is a -partial fix, not parity with an indexed ` Date: Wed, 29 Jul 2026 09:41:57 +0200 Subject: [PATCH 50/86] Answer utf8 scalar predicates from the rank index b1bbc54e indexed utf8 columns by alphabetical rank but only ordering used it; a scalar comparison still scanned the whole byte blob. Rank order is lexicographic order, so a literal maps to a rank by one searchsorted and the matching rows are a contiguous run of the sorted-positions sidecar. The vocabulary is what makes the lookup possible, so it is now persisted in rank order beside the index's other sidecars (inlined in the descriptor for in-memory indexes, which are small by construction) and cached per table on first use. Re-deriving it instead would mean factorizing the column on every query, which costs more than the scan it replaces. Everything hangs off Column._utf8_scalar_mask, which every scalar predicate already funnels through, so nothing downstream changes; it returns None and falls back to the scan whenever the index cannot answer -- no index, wrong kind, stale, or in-memory sidecars. Mask construction at 1M rows, cardinality 20k: == 29.00 ms -> 5.49 ms != 28.55 ms -> 10.24 ms < 34.57 ms -> 5.45 ms >= 34.16 ms -> 8.07 ms != is built by inverting over the column's own rows rather than the capacity-padded physical mask -- inverting the full mask turns padding slots True, which a first cut did, giving 1048517 hits where the scan gives 999941. Nulls carry the largest rank and are excluded rather than inverted into, so a null still satisfies no comparison. Checked against the scan over 252 randomized probes spanning nullable and non-nullable columns, absent literals and the empty string. Co-Authored-By: Claude Opus 5 --- src/blosc2/ctable.py | 103 ++++++++++++++++++++++++++++++++++ src/blosc2/ctable_indexing.py | 32 ++++++++++- tests/ctable/test_utf8.py | 67 ++++++++++++++++++++++ 3 files changed, 200 insertions(+), 2 deletions(-) diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index c2b747c73..5bb0961a9 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -2116,6 +2116,10 @@ def _utf8_scalar_mask(self, numpy_op, value: str) -> np.ndarray: """ nv = self.null_value + indexed = self._utf8_index_mask(numpy_op, value) + if indexed is not None: + return indexed + if numpy_op in (np.equal, np.not_equal): def fn(arr, start, stop): @@ -2143,6 +2147,82 @@ def fn(arr, start, stop): return self._utf8_chunked_bytes(fn) + #: Rank predicate implied by each comparison, given the literal's insertion + #: point ``lo`` and whether the literal is itself in the vocabulary. + _UTF8_RANK_PREDICATE: ClassVar[dict] = { + "equal": lambda lo, hit: (lo, lo + 1) if hit else None, + "not_equal": lambda lo, hit: (lo, lo + 1) if hit else None, # inverted by caller + "less": lambda lo, hit: (0, lo), + "less_equal": lambda lo, hit: (0, lo + 1 if hit else lo), + "greater": lambda lo, hit: (lo + 1 if hit else lo, None), + "greater_equal": lambda lo, hit: (lo, None), + } + + def _utf8_index_mask(self, numpy_op, value: str) -> np.ndarray | None: + """Answer ``column value`` from the rank index, or ``None``. + + The index sorts rows by alphabetical rank, so a literal is located by + one ``searchsorted`` over the stored vocabulary and the matching rows + are a contiguous run of the sorted-positions sidecar — no scan of the + column at all. Returns ``None`` whenever the index cannot answer, and + the caller falls back to the raw-byte scan. + """ + table = self._table + descriptor = table._get_index_catalog().get(self._col_name) + if not descriptor or descriptor.get("kind") != "full" or descriptor.get("stale", False): + return None + full = descriptor.get("full") or {} + meta = full.get("utf8_rank") + if meta is None or table._utf8_rank_index_stale(self._col_name, meta): + return None + positions_path = full.get("positions_path") + values_path = full.get("values_path") + if positions_path is None or values_path is None: # in-memory sidecars + return None + + vocab = table._utf8_index_vocab(self._col_name, meta) + if vocab is None: + return None + lo = int(np.searchsorted(vocab, value, side="left")) + hit = lo < len(vocab) and vocab[lo] == value + bounds = self._UTF8_RANK_PREDICATE[numpy_op.__name__](lo, hit) + + from blosc2.indexing import _open_sidecar_file + + vnd = _open_sidecar_file(values_path) + pnd = _open_sidecar_file(positions_path) + null_rank = meta["null_rank"] + n_phys = len(table._valid_rows) + + def rows_for_ranks(rank_lo, rank_hi) -> np.ndarray: + """Physical rows whose rank is in ``[rank_lo, rank_hi)``. + + ``rank_hi is None`` means "up to but excluding the nulls", which + carry the largest rank — a null satisfies no comparison. + """ + start = table._sidecar_bisect(vnd, rank_lo, "left") + stop = ( + table._sidecar_bisect(vnd, null_rank, "left") + if rank_hi is None + else table._sidecar_bisect(vnd, rank_hi - 1, "right") + ) + if stop <= start: + return np.empty(0, dtype=np.int64) + return np.asarray(pnd[start:stop], dtype=np.int64) + + mask = np.zeros(n_phys, dtype=bool) + if numpy_op is np.not_equal: + # Invert over the column's own rows only: the physical mask is + # capacity-padded, and padded slots must stay False, as they do on + # the scan path. Nulls are excluded rather than inverted into. + mask[: len(table._cols[self._col_name])] = True + if bounds is not None: + mask[rows_for_ranks(*bounds)] = False + mask[rows_for_ranks(null_rank, None if null_rank == 0 else null_rank + 1)] = False + elif bounds is not None: + mask[rows_for_ranks(*bounds)] = True + return mask + def _utf8_compare_scalar(self, numpy_op, value: str): """Scalar comparison as a live-row-intersected boolean NDArray.""" return blosc2.asarray(self._utf8_scalar_mask(numpy_op, value)) & self._lazy_valid_rows() @@ -4087,6 +4167,29 @@ def _dict_rank_index_stale(self, name: str, dict_rank_meta: dict) -> bool: return True return _dict_rank_hash(dictionary) != dict_rank_meta.get("dict_hash") + def _utf8_index_vocab(self, name: str, utf8_rank_meta: dict) -> np.ndarray | None: + """Rank-ordered vocabulary for a utf8 index, cached per table. + + Small relative to the column (one entry per distinct value) and read + once, so a literal→rank lookup costs a ``searchsorted`` rather than a + re-factorization of the column. + """ + cache = self.__dict__.setdefault("_utf8_vocab_cache", {}) + key = (name, utf8_rank_meta.get("n_rows"), utf8_rank_meta.get("nbytes")) + if key in cache: + return cache[key] + inline = utf8_rank_meta.get("vocab") + if inline is not None: + vocab = np.array(inline, dtype=np.str_) if inline else np.empty(0, dtype=np.str_) + else: + path = utf8_rank_meta.get("vocab_path") + if path is None or not os.path.exists(path): + return None + vocab = np.asarray(blosc2.open(path, mode="r")[:]) + cache.clear() # only the current build's vocabulary is ever of interest + cache[key] = vocab + return vocab + def _utf8_rank_index_stale(self, name: str, utf8_rank_meta: dict) -> bool: """True if a utf8-rank FULL index no longer matches the live column. diff --git a/src/blosc2/ctable_indexing.py b/src/blosc2/ctable_indexing.py index 7d55e736f..6b7f98a5a 100644 --- a/src/blosc2/ctable_indexing.py +++ b/src/blosc2/ctable_indexing.py @@ -13,6 +13,7 @@ import ast import contextlib import os +import pathlib from typing import TYPE_CHECKING, Any import numpy as np @@ -109,6 +110,9 @@ def _utf8_rank_arrays(col, n_live: int, null_value: str | None): if is_null.any(): code_to_rank[is_null] = null_rank + # Rank order == alphabetical order, so this doubles as the lookup table that + # turns a query literal into a rank (np.searchsorted) without touching data. + sorted_vocab = uniques[order] ranks = code_to_rank[codes] if n_entries else np.zeros(n_live, dtype=np.int32) # Staleness signals must be O(1) to check: re-deriving the vocabulary would # mean factorizing the column again on every query. Any write already marks @@ -120,7 +124,28 @@ def _utf8_rank_arrays(col, n_live: int, null_value: str | None): "n_rows": int(n_live), "nbytes": int(col._bytes_used), } - return ranks.astype(np.int32, copy=False), meta + return ranks.astype(np.int32, copy=False), meta, sorted_vocab + + +def _persist_utf8_vocab(full: dict, meta: dict, sorted_vocab: np.ndarray) -> None: + """Store the rank-ordered vocabulary so a query literal can be turned into a rank. + + Written beside the index's own sidecars when the table is persistent, and + inlined into the descriptor otherwise — the in-memory index path is for + small tables by construction. Without it a literal→rank lookup would mean + factorizing the column again on every query. + """ + if len(sorted_vocab) == 0: + meta["vocab"] = [] + return + values_path = full.get("values_path") + if values_path is None: # in-memory index + meta["vocab"] = sorted_vocab.tolist() + return + width = max(len(v) for v in sorted_vocab) + vocab_path = str(pathlib.Path(values_path).with_suffix("")) + ".utf8_vocab.b2nd" + blosc2.asarray(sorted_vocab.astype(f"", np.greater), + (">=", np.greater_equal), + ): + for probe in ("apple", "pear", "zzz-absent", ""): + got[(name, probe)] = col._utf8_scalar_mask(op, probe).copy() + masks[tag] = got + if tag == "index": + # The fast path must really have been taken, not silently skipped. + assert col._utf8_index_mask(np.equal, "apple") is not None + del t + + for key, scanned in masks["scan"].items(): + np.testing.assert_array_equal(masks["index"][key], scanned, err_msg=f"{key}") + + +def test_ctable_utf8_index_predicate_falls_back_when_stale(tmp_path): + """A stale rank index must not answer predicates from frozen ranks.""" + from dataclasses import make_dataclass + + import numpy as np + + row_cls = make_dataclass("Row", [("c", str, blosc2.field(blosc2.utf8()))]) + t = blosc2.CTable(row_cls, urlpath=str(tmp_path / "t.b2t"), mode="w") + t.extend({"c": ["pear", "banana"]}, validate=False) + t._flush_varlen_columns() + t.create_index("c", kind="full") + assert t["c"]._utf8_index_mask(np.equal, "pear") is not None + + t.append({"c": "apple"}) # a value ahead of the others invalidates every rank + t._flush_varlen_columns() + assert t["c"]._utf8_index_mask(np.equal, "pear") is None + np.testing.assert_array_equal(t["c"]._utf8_scalar_mask(np.equal, "apple")[:3], [False, False, True]) From 93b45ace4990362fa94fd50fecd57d47d13f3492 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 09:42:15 +0200 Subject: [PATCH 51/86] Record indexed utf8 predicates in the assessment Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index dab12f4ee..159627b17 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -26,7 +26,7 @@ because two of the conclusions below originally rested on it. | `sum(where=…)` | ✓ | ✓ | ✓ | ✗ | ✗ | | `sort_by` | ✓ | ✓ | ✓ | ✓ | ✗ TypeError | | `group_by` | ✓ | ✓ | ✓ | ✓ | ✗ | -| `create_index` | ✓ all 5 kinds | ✓ all 5 kinds | ✓ rank, ordering only ⁷ | ✓ rank, ordering only | ✗ | +| `create_index` | ✓ all 5 kinds | ✓ all 5 kinds | ✓ rank, ordering + predicates ⁷ | ✓ rank, ordering only | ✗ | | **Compute (string-returning)** | | | | | | | `add_computed_column("'x='+c")` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | | `assign(new=…)` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | @@ -335,14 +335,25 @@ cardinality 20 k, persistent: build, because it sorts `int32` ranks rather than `=` | 34.16 ms | 8.07 ms | + +It hangs off `Column._utf8_scalar_mask`, which every scalar predicate already funnels through, and +returns `None` to fall back to the scan whenever the index cannot answer. Note the end-to-end +`where()` gain is smaller than these numbers — materializing the result rows out of the +offsets/blob dominates once the mask is cheap. + +`plan_query` is still never consulted for a utf8 or dictionary predicate; this route bypasses it +rather than fixing that. **Dictionary still has no predicate acceleration** — it has the vocabulary +(its own dictionary) but nothing wires it to its rank index. **Also found here:** NumPy 2.4 does not match a lone `"\x00"` against a `StringDType` array (`np.array(["\x00"], dtype=StringDType()) == "\x00"` is `False`), while `"\x00x"` and `"a\x00b"` From 1c22fdf51127f5aa1b8ace011248a9049ff2e66f Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 10:09:37 +0200 Subject: [PATCH 52/86] Serve dictionary equality from the rank index, and fix != while there Three related changes, all in the dictionary predicate path. Column._dictionary_index_mask() answers `col == value` from the dict-rank index the same way f132d6df does for utf8, minus the persistence: a dictionary already holds its vocabulary in memory, so the literal's rank is a searchsorted over the sorted dictionary and the matching rows are a contiguous run of the positions sidecar. The operator form goes 329.6 ms -> 8.4 ms at 1M rows, cardinality 20k. _dict_rank_index_stale() settles from the value epoch before hashing. Hashing a 20k-entry dictionary costs 24 ms, more than the scan the index was meant to save, and it ran on every query -- including the ordering path that already used the index, whose sorted_slice drops 51.3 ms -> 37.7 ms as a result. `col != value` raised IndexError on any table with capacity padding: __ne__ negated the result of _dictionary_eq(), which had already been intersected with the live-row mask, so ~(pred & valid) turned every dead slot True and the oversized mask failed to index the row array. The negation now happens on the value test, before the intersection. Pre-existing, unrelated to the index; found by fuzzing indexed against unindexed results. Deliberately NOT applied to the where("c == 'x'") string form: rewriting to a code comparison keeps it one fused numeric expression, and substituting a precomputed mask measured slower (22.9 ms -> 28.7 ms) even though the mask costs 4.8 ms. That form needs the planner to consume index positions rather than a mask. Co-Authored-By: Claude Opus 5 --- src/blosc2/ctable.py | 134 +++++++++++++++++++------ tests/ctable/test_dictionary_column.py | 86 ++++++++++++++++ 2 files changed, 189 insertions(+), 31 deletions(-) diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 5bb0961a9..57145f633 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -541,6 +541,33 @@ def __repr__(self) -> str: # --------------------------------------------------------------------------- +def _rank_index_row_lookup(values_path: str, positions_path: str, table, null_rank: int): + """Build ``rows_for_ranks(lo, hi)`` over a rank index's sorted sidecars. + + Shared by the utf8 and dictionary rank indexes, which differ only in how a + literal becomes a rank. Returns the physical rows whose rank lies in + ``[lo, hi)``; ``hi is None`` means "up to but excluding the nulls", which + carry the largest rank because a null satisfies no comparison. + """ + from blosc2.indexing import _open_sidecar_file + + vnd = _open_sidecar_file(values_path) + pnd = _open_sidecar_file(positions_path) + + def rows_for_ranks(rank_lo, rank_hi) -> np.ndarray: + start = table._sidecar_bisect(vnd, rank_lo, "left") + stop = ( + table._sidecar_bisect(vnd, null_rank, "left") + if rank_hi is None + else table._sidecar_bisect(vnd, rank_hi - 1, "right") + ) + if stop <= start: + return np.empty(0, dtype=np.int64) + return np.asarray(pnd[start:stop], dtype=np.int64) + + return rows_for_ranks + + def _find_physical_index(arr: blosc2.NDArray, logical_key: int) -> int: """Translate a logical (valid-row) index into a physical array index. @@ -2015,10 +2042,7 @@ def __eq__(self, other): def __ne__(self, other): if self.is_dictionary: - result = self._dictionary_eq(other) - if isinstance(result, np.ndarray): - return ~result - return ~np.asarray(result, dtype=bool) + return self._dictionary_eq(other, negate=True) if self.is_utf8: return self._utf8_compare(np.not_equal, other) self._ensure_comparable() @@ -2187,28 +2211,9 @@ def _utf8_index_mask(self, numpy_op, value: str) -> np.ndarray | None: hit = lo < len(vocab) and vocab[lo] == value bounds = self._UTF8_RANK_PREDICATE[numpy_op.__name__](lo, hit) - from blosc2.indexing import _open_sidecar_file - - vnd = _open_sidecar_file(values_path) - pnd = _open_sidecar_file(positions_path) null_rank = meta["null_rank"] n_phys = len(table._valid_rows) - - def rows_for_ranks(rank_lo, rank_hi) -> np.ndarray: - """Physical rows whose rank is in ``[rank_lo, rank_hi)``. - - ``rank_hi is None`` means "up to but excluding the nulls", which - carry the largest rank — a null satisfies no comparison. - """ - start = table._sidecar_bisect(vnd, rank_lo, "left") - stop = ( - table._sidecar_bisect(vnd, null_rank, "left") - if rank_hi is None - else table._sidecar_bisect(vnd, rank_hi - 1, "right") - ) - if stop <= start: - return np.empty(0, dtype=np.int64) - return np.asarray(pnd[start:stop], dtype=np.int64) + rows_for_ranks = _rank_index_row_lookup(values_path, positions_path, table, null_rank) mask = np.zeros(n_phys, dtype=bool) if numpy_op is np.not_equal: @@ -2227,7 +2232,47 @@ def _utf8_compare_scalar(self, numpy_op, value: str): """Scalar comparison as a live-row-intersected boolean NDArray.""" return blosc2.asarray(self._utf8_scalar_mask(numpy_op, value)) & self._lazy_valid_rows() - def _dictionary_eq(self, other): + def _dictionary_index_mask(self, value: str) -> np.ndarray | None: + """Answer ``column == value`` from the dict-rank index, or ``None``. + + The same lookup the utf8 rank index does, minus the persistence: a + dictionary already holds its own vocabulary in memory, so the literal's + rank is a ``searchsorted`` over the sorted dictionary. Returns ``None`` + whenever the index cannot answer, and the caller falls back to the + codes comparison. + """ + table = self._table + descriptor = table._get_index_catalog().get(self._col_name) + if not descriptor or descriptor.get("kind") != "full" or descriptor.get("stale", False): + return None + full = descriptor.get("full") or {} + meta = full.get("dict_rank") + if meta is None or table._dict_rank_index_stale(self._col_name, meta): + return None + values_path, positions_path = full.get("values_path"), full.get("positions_path") + if positions_path is None or values_path is None: # in-memory sidecars + return None + + # Ranks were assigned by argsort over the dictionary, so the rank of a + # literal is its position in the sorted dictionary. + dc = self._raw_col + cache = table.__dict__.setdefault("_dict_vocab_cache", {}) + key = (self._col_name, meta.get("dict_hash")) + sorted_vocab = cache.get(key) + if sorted_vocab is None: + cache.clear() + sorted_vocab = np.sort(np.asarray(list(dc.dictionary), dtype=np.str_)) + cache[key] = sorted_vocab + lo = int(np.searchsorted(sorted_vocab, value, side="left")) + if lo >= len(sorted_vocab) or sorted_vocab[lo] != value: + return np.zeros(len(table._valid_rows), dtype=bool) + + rows_for_ranks = _rank_index_row_lookup(values_path, positions_path, table, meta["null_rank"]) + mask = np.zeros(len(table._valid_rows), dtype=bool) + mask[rows_for_ranks(lo, lo + 1)] = True + return mask + + def _dictionary_eq(self, other, *, negate: bool = False): """Return a physical-slot boolean predicate for dictionary equality. Regular fixed-width columns build predicates against their raw physical @@ -2235,25 +2280,37 @@ def _dictionary_eq(self, other): need to use the same coordinate system so they can be combined with regular predicates before aggregate/view code intersects them with ``_valid_rows``. + + *negate* inverts the value test *before* the live-row intersection, so + ``!=`` stays a same-shaped predicate over live rows. Negating the + returned value instead would turn every dead slot True. """ + n_phys = len(self._table._valid_rows) dc = self._raw_col # DictionaryColumn spec = self._table._schema.columns_by_name[self._col_name].spec + valid = self._lazy_valid_rows() if other is None: target_code = spec.null_code elif isinstance(other, str): + indexed = self._dictionary_index_mask(other) + if indexed is not None: + return blosc2.asarray(~indexed if negate else indexed) & valid try: target_code = dc.value_to_code(other) except KeyError: - return blosc2.zeros(len(self._table._valid_rows), dtype=np.bool_) + # No row carries this value: nothing matches, everything differs. + if negate: + return blosc2.ones(n_phys, dtype=np.bool_) & valid + return blosc2.zeros(n_phys, dtype=np.bool_) else: raise TypeError( f"Dictionary column {self._col_name!r} can only be compared with str or None, " f"got {type(other).__name__!r}." ) - pred = dc.codes == np.int32(target_code) - valid = self._lazy_valid_rows() - if len(dc.codes) != len(self._table._valid_rows): - physical = blosc2.zeros(len(self._table._valid_rows), dtype=np.bool_) + code = np.int32(target_code) + pred = dc.codes != code if negate else dc.codes == code + if len(dc.codes) != n_phys: + physical = blosc2.zeros(n_phys, dtype=np.bool_) physical[: len(dc.codes)] = pred pred = physical return pred & valid @@ -4159,9 +4216,17 @@ def _dict_rank_index_stale(self, name: str, dict_rank_meta: dict) -> bool: """ from blosc2.ctable_indexing import _dict_rank_hash - col = self._root_table._cols.get(name) + root = self._root_table + col = root._cols.get(name) if col is None: return True + # Hashing the whole dictionary costs more than the scan this check is + # meant to let us skip (24 ms for 20k entries), so settle it from the + # value epoch first: unchanged epoch means nothing has been written + # since the index was built, so the ranks cannot have moved. + built_epoch = (root._get_index_catalog().get(name) or {}).get("built_value_epoch") + if built_epoch is not None and root._storage.get_epoch_counters()[0] == built_epoch: + return False dictionary = list(col.dictionary) if len(dictionary) != dict_rank_meta.get("dict_len"): return True @@ -12724,6 +12789,13 @@ def _rewrite_dictionary_predicates( def eq_repl(match: re.Match, _dc=dc, _name=name) -> str: value = ast.literal_eval(match.group(2)) + # Deliberately *not* served from the rank index, unlike the + # operator form: rewriting to a code comparison keeps this a + # single fused numeric expression, and substituting a + # precomputed mask instead measured consistently slower + # (22.9 ms -> 28.7 ms at 1M rows) even though the mask itself + # costs only 4.8 ms. Accelerating this form needs the planner + # to consume index positions, not a mask. try: code = int(_dc.value_to_code(value)) except KeyError: diff --git a/tests/ctable/test_dictionary_column.py b/tests/ctable/test_dictionary_column.py index 4d5a95283..c9791120c 100644 --- a/tests/ctable/test_dictionary_column.py +++ b/tests/ctable/test_dictionary_column.py @@ -600,3 +600,89 @@ class Row: np.testing.assert_array_equal((col == "absent")[:3], [False, False, False]) # Defining __eq__ must not have made the container unhashable. assert isinstance(hash(col), int) + + +def test_dictionary_ne_predicate_matches_live_rows(): + """``col != value`` must negate the value test, not the live-row mask. + + Negating afterwards turned every dead capacity slot True, which then failed + with an IndexError when used to select rows. + """ + import numpy as np + + @dataclass + class Row: + c: str = blosc2.field(blosc2.dictionary()) + + values = ["a1", "b2", "c3"] * 13 # 39 live rows in a padded slot array + t = CTable(Row) + t.extend({"c": values}, validate=False) + t._flush_varlen_columns() + + assert sorted(t[t["c"] != "a1"]["c"][:]) == sorted(v for v in values if v != "a1") + assert len(t[t["c"] == "a1"]["c"][:]) == 13 + # A value no row carries: nothing matches, everything differs. + assert len(t[t["c"] == "absent"]["c"][:]) == 0 + assert len(t[t["c"] != "absent"]["c"][:]) == len(values) + assert np.asarray((t["c"] != "a1")[:]).sum() == 26 + + +def test_dictionary_index_answers_equality(tmp_path): + """With a rank index, ``col == value`` is a sidecar lookup, not a codes scan.""" + + @dataclass + class Row: + c: str = blosc2.field(blosc2.dictionary()) + + values = ["pear", "apple", "cherry", "apple", "banana"] + results = {} + for tag in ("scan", "index"): + t = CTable(Row, urlpath=str(tmp_path / f"{tag}.b2t"), mode="w") + t.extend({"c": values}, validate=False) + t._flush_varlen_columns() + if tag == "index": + t.create_index("c", kind="full") + assert t["c"]._dictionary_index_mask("apple") is not None + # A value absent from the dictionary still answers, matching nothing. + assert not t["c"]._dictionary_index_mask("absent").any() + results[tag] = { + probe: ( + sorted(t[t["c"] == probe]["c"][:]), + sorted(t[t["c"] != probe]["c"][:]), + ) + for probe in ("apple", "pear", "absent") + } + del t + + assert results["index"] == results["scan"] + assert results["scan"]["apple"][0] == ["apple", "apple"] + + +def test_dict_rank_index_staleness_uses_the_value_epoch(tmp_path): + """The staleness check must not re-hash the whole dictionary per query.""" + from blosc2.ctable_indexing import _dict_rank_hash + + @dataclass + class Row: + c: str = blosc2.field(blosc2.dictionary()) + + t = CTable(Row, urlpath=str(tmp_path / "t.b2t"), mode="w") + t.extend({"c": [f"v{i % 50}" for i in range(500)]}, validate=False) + t._flush_varlen_columns() + t.create_index("c", kind="full") + meta = t._get_index_catalog()["c"]["full"]["dict_rank"] + + calls = 0 + import blosc2.ctable_indexing as ci + + def counting_hash(dictionary): + nonlocal calls + calls += 1 + return _dict_rank_hash(dictionary) + + ci._dict_rank_hash = counting_hash + try: + assert not t._dict_rank_index_stale("c", meta) + finally: + ci._dict_rank_hash = _dict_rank_hash + assert calls == 0, "value epoch was unchanged, so no hash should have been needed" From 7174109b3eda7c232929c62efd92b8b0c331302c Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 10:09:55 +0200 Subject: [PATCH 53/86] Record dictionary index predicates in the assessment Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 159627b17..dfe443a37 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -26,7 +26,7 @@ because two of the conclusions below originally rested on it. | `sum(where=…)` | ✓ | ✓ | ✓ | ✗ | ✗ | | `sort_by` | ✓ | ✓ | ✓ | ✓ | ✗ TypeError | | `group_by` | ✓ | ✓ | ✓ | ✓ | ✗ | -| `create_index` | ✓ all 5 kinds | ✓ all 5 kinds | ✓ rank, ordering + predicates ⁷ | ✓ rank, ordering only | ✗ | +| `create_index` | ✓ all 5 kinds | ✓ all 5 kinds | ✓ rank, ordering + predicates ⁷ | ✓ rank, ordering + `==`/`!=` ⁷ | ✗ | | **Compute (string-returning)** | | | | | | | `add_computed_column("'x='+c")` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | | `assign(new=…)` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | @@ -351,9 +351,25 @@ returns `None` to fall back to the scan whenever the index cannot answer. Note t `where()` gain is smaller than these numbers — materializing the result rows out of the offsets/blob dominates once the mask is cheap. -`plan_query` is still never consulted for a utf8 or dictionary predicate; this route bypasses it -rather than fixing that. **Dictionary still has no predicate acceleration** — it has the vocabulary -(its own dictionary) but nothing wires it to its rank index. +**Dictionary followed in `1c22fdf5`**, minus the persistence — a dictionary already holds its +vocabulary in memory. The operator form `t[t.c == v]` goes **329.6 ms → 8.4 ms**. + +Two things surfaced while wiring it, both worth more than the feature: + +- **The staleness check cost more than the scan it saved.** `_dict_rank_index_stale` SHA1s the whole + dictionary — 24 ms for 20 k entries — on *every* query, including the ordering path that already + used the index. It now settles from the value epoch first, which also drops dictionary + `sorted_slice` from 51.3 ms to 37.7 ms. Wiring the index made queries *slower* until this was found. +- **`col != value` raised `IndexError`** on any table with capacity padding: `__ne__` negated the + result of `_dictionary_eq`, which had already been intersected with the live-row mask, so + `~(pred & valid)` turned every dead slot True. Pre-existing and unrelated to indexing; found by + fuzzing indexed against unindexed results. + +**The `where("c == 'x'")` string form is deliberately left alone** for both flavours. Rewriting to a +code comparison keeps it a single fused numeric expression; substituting a precomputed mask measured +*slower* (22.9 ms → 28.7 ms) even though the mask costs 4.8 ms. `plan_query` is still never consulted +for a utf8 or dictionary predicate — both routes bypass it rather than fix it, and accelerating the +string form needs the planner to consume index *positions* rather than a mask. **Also found here:** NumPy 2.4 does not match a lone `"\x00"` against a `StringDType` array (`np.array(["\x00"], dtype=StringDType()) == "\x00"` is `False`), while `"\x00x"` and `"a\x00b"` From e707c91c3b6ae97a5a47e4664d8ac2e999f4c4f7 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 10:20:55 +0200 Subject: [PATCH 54/86] Update the user-facing docs for the string index work The reference table and the utf8() docstring still told users that create_index is unsupported on utf8 and that a fixed-width string column is the choice when an index is needed -- both false since b1bbc54e. The ChoosingStringType table now marks utf8 and dictionary as rank-indexed, with a footnote covering what a rank index does and does not accelerate and the fact that ranks go stale when a value is inserted ahead of existing ones. Also adds the 4.9.2 release notes for this line of work: the utf8 index and indexed scalar comparisons, the dictionary per-row decode and BUCKET pessimization fixes, the comparison operators that returned a plain False on three container types, the dictcol != IndexError, bare Utf8Array expressions taking the NumPy fallback, and the rejected lone-NUL null_value. Verified by building the docs: no new warnings from either file, the table renders both rank-based cells, and no "not yet" text survives. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 47 ++++++++++++++++++++++++++++++++++++++++ doc/reference/ctable.rst | 22 +++++++++++++------ src/blosc2/schema.py | 12 +++++----- 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7bc7d48dc..e6239362f 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -81,6 +81,53 @@ XXX version-specific blurb XXX matching numpy. - Not implemented: `bytes` (returns raw `bytes`, not an `NDArray`). +- **`create_index()` now works on `utf8()` columns**, the last string flavour + without one. Both `utf8` and `dictionary` are indexed by the *alphabetical + rank* of each value: sorting by rank is sorting by the decoded string, so an + `int32` rank column drives the same machinery a numeric column uses. At 1M + rows / cardinality 20k: `sort_by` 424 ms -> 7.2 ms, `sorted_slice` 458 ms -> + 43 ms, and the index is the cheapest of the three flavours to build (277 ms + against 867 ms for ` 5.5 ms, `<` 34.6 ms -> 5.5 ms, and the dictionary operator + form `t[t.c == v]` 329.6 ms -> 8.4 ms. `startswith`/substring searches are + not accelerated (no index covers them), and ranks are frozen at build time, + so a value inserted ahead of existing ones sends the index stale until it is + rebuilt. + +### Improvements + +- **Dictionary columns decode once per read, not once per row.** Each + `dict_store[code]` decompresses a whole msgpack batch, so reads and + lexsort-based `sort_by` cost O(N) decompressions. At 1M rows an unindexed + `sort_by` drops from 236 s to 713 ms, and a full column read from 44 s (at + 200k rows) to 193 ms. +- **`kind=BUCKET` indexes no longer cost more than the scan they replace.** + Scattered matches were read one bucket run at a time, re-decompressing the + same blocks many times, and the planner measured selectivity in buckets while + the cost is paid in blocks — a mask selecting 21% of buckets could touch 96% + of them. Affected every indexable dtype; the relative penalty was worst on + numerics (`float64` 6.3 ms -> 77.9 ms before, 6.6 ms after). + +### Bug fixes + +- **Comparison operators on `Utf8Array`, dictionary and varlen scalar columns** + returned a plain `False`: none defined them, so `column == "value"` fell + through to object identity. Silently wrong rather than an error. All now + return boolean masks; `Utf8Array` and `DictionaryColumn` answer a scalar + without decoding any row. +- **`dictcol != value` raised `IndexError`** on any table with capacity + padding: the negation was applied after the live-row intersection, turning + every dead slot `True`. +- **Expressions over a bare `Utf8Array`** (`blosc2.lazyexpr("'x=' + a", {"a": + arr})`) produced correct values down the wrong path — widened to fixed-width + ` Utf8Spec: utf8 columns support vectorized comparisons (``==``, ``!=``, ``<``, ``<=``, ``>``, ``>=``), string-expression filters such as ``t.where("name == 'x'")`` and ``t.where("startswith(name, 'x')")``, - :meth:`CTable.group_by` keys, :meth:`CTable.sort_by`, and Arrow/Parquet - interop. Current limitation: :meth:`CTable.create_index` is not - supported yet (use a fixed-width :class:`string` column if you need an - index). See :ref:`ChoosingStringType` for a full comparison with - :class:`string` and :func:`vlstring`. + :meth:`CTable.group_by` keys, :meth:`CTable.sort_by`, Arrow/Parquet + interop, and :meth:`CTable.create_index`, which indexes the alphabetical + rank of each value and accelerates sorting and scalar comparisons (but + not ``startswith``/substring searches, which no index covers). Nested + (dotted) utf8 leaves in an expression are not supported yet. See + :ref:`ChoosingStringType` for a full comparison with :class:`string` and + :func:`vlstring`. Parameters ---------- From e273ef1f72d99d0e58795d7ec48b3eea9924b377 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 10:21:09 +0200 Subject: [PATCH 55/86] Mark the doc update done in the assessment Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index dfe443a37..a404b9ecc 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -308,8 +308,11 @@ Found while measuring, unrelated to the utf8 decision: - ~~`sort_by` on an unindexed `dictionary` column takes 235 s at 1 M rows~~ — **fixed**, `a9446841`. Per-row decode; now 759 ms. - ~~`kind=BUCKET` is a pessimization~~ — **fixed**, `92c39aa6`. Affected every indexable dtype. -- Still open: the doc table's plain ✓ for `create_index` on `dictionary` should read "ordering - only", and `_build_lex_keys` could sort dictionary ranks instead of decoded strings (~4×). +- ~~The doc table's `create_index` entries are stale~~ — **done**, `e707c91c`. The reference table + and the `utf8()` docstring both claimed utf8 could not be indexed, which my own change had made + false; 4.9.2 release notes added for this whole line of work. +- Still open: `_build_lex_keys` could sort dictionary ranks instead of decoded strings (~4×), and + the `where("c == 'x'")` string form still bypasses the index for both flavours. ⁶ `blosc2.lazyexpr` over a bare `DictionaryColumn` returns the **capacity-padded** slot array — 1 048 576 rows for a 3-row table. Not a container bug: `DictionaryColumn.__len__` is documented as From f8af071479d06414ce02188dbf58fd4c570e2d50 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 12:58:54 +0200 Subject: [PATCH 56/86] Let add_column() fill a new column from values= The assessment's conversion rule for utf8 -- compute on fixed-width, write the result back -- had no supported final step: add_column() could only backfill from a declared default, so landing a computed result meant the private t._cols[name].set_all(...). values= takes one entry per live row and coerces it to the column's dtype, and a declared default still applies to rows appended later, so the two combine. Two things fixed on the way, both reached by the new path but neither caused by it: - add_column() on a varlen column (vlstring/vlbytes/utf8/struct/object) filled only as many entries as there were live rows, while the table indexes those columns by physical position. On a table with deleted rows the first read raised IndexError. The dead slots are now filled too. - add_column() on a dictionary column raised AttributeError from inside the fixed-width path, since DictionarySpec has no dtype. It now raises TypeError naming the limitation, next to the list-column guard. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 15 ++++ doc/reference/ctable.rst | 18 ++++ src/blosc2/ctable.py | 121 ++++++++++++++++++++++---- tests/ctable/test_schema_mutations.py | 109 +++++++++++++++++++++++ tests/ctable/test_utf8.py | 33 +++++++ 5 files changed, 281 insertions(+), 15 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e6239362f..7ee057c6b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -94,6 +94,14 @@ XXX version-specific blurb XXX so a value inserted ahead of existing ones sends the index stale until it is rebuilt. +- **`CTable.add_column()` accepts `values=`**, a sequence with one entry per + live row, as an alternative to backfilling from a declared default. This is + the supported way to land a result computed outside the table back into it, + which matters most for `utf8()` columns: string-returning expressions are + evaluated on fixed-width arrays, and the result previously had to be written + through the private `t._cols[name].set_all(...)`. A declared default is still + honoured for rows appended later, so the two can be combined. + ### Improvements - **Dictionary columns decode once per read, not once per row.** Each @@ -123,6 +131,13 @@ XXX version-specific blurb XXX ` None: - """Add a new column filled from the default declared in *spec*. + """Add a new column filled from *values*, or from the default declared in *spec*. Parameters ---------- @@ -8973,16 +8974,30 @@ def add_column( # noqa: C901 spec: A schema descriptor such as ``b2.int64(ge=0)`` or a field descriptor such as ``b2.field(b2.int64(ge=0), default=0)``. - When the table already has live rows, use ``blosc2.field(...)`` - with a default declared so those rows can be backfilled. + When the table already has live rows and no *values* are given, + use ``blosc2.field(...)`` with a default declared so those rows + can be backfilled. + values: + Optional sequence with one entry per **live** row, in row order, + used to fill the new column. This is the supported way to land a + computed result back into the table:: + + res = blosc2.lazyexpr("'x=' + a", {"a": blosc2.asarray(arr)}).compute() + t.add_column("out", blosc2.utf8(), values=res[:]) + + A declared default is still honoured for rows appended later, so + *values* and ``blosc2.field(..., default=...)`` can be combined. Raises ------ ValueError If the table is read-only, is a view, the column already exists, - or a non-empty table is given a column with no default declared. + a non-empty table is given a column with neither *values* nor a + default declared, or ``len(values)`` does not match the number of + live rows. TypeError - If a declared default cannot be coerced to *spec*'s dtype. + If a declared default, or *values*, cannot be coerced to *spec*'s + dtype. """ if self._read_only: raise ValueError("Table is read-only (opened with mode='r').") @@ -8997,10 +9012,10 @@ def add_column( # noqa: C901 spec, default, column_config = self._column_spec_default_and_config(spec) n_live = self.nrows - if default is MISSING and n_live > 0: + if values is None and default is MISSING and n_live > 0: raise ValueError( "add_column() requires a default declared as blosc2.field(..., default=...) " - "when the table has live rows." + "or a values= sequence when the table has live rows." ) compiled_col = self._compiled_column_from_spec(name, spec) @@ -9010,9 +9025,22 @@ def add_column( # noqa: C901 validate_column_null_values=False, ) spec = compiled_col.spec + if self._is_list_column(compiled_col): + raise TypeError( + "add_column() does not support list columns; use the constructor with a full schema." + ) + if self._is_dictionary_column(compiled_col): + raise TypeError( + "add_column() does not support dictionary columns; use the constructor with a full schema." + ) + if values is not None: + values = self._add_column_values(name, compiled_col, values, n_live) if self._is_varlen_scalar_column(compiled_col): - # Varlen scalar columns don't use fixed-width NDArray storage. + # Varlen scalar columns don't use fixed-width NDArray storage, but the + # table still indexes them by *physical* position, so a new one has to + # span the physical extent rather than just the live rows -- otherwise + # any table with deleted rows reads past its end. col_storage = self._resolve_column_storage(compiled_col, None, None) new_col = self._storage.create_varlen_scalar_column( name, @@ -9020,13 +9048,19 @@ def add_column( # noqa: C901 cparams=col_storage.get("cparams"), dparams=col_storage.get("dparams"), ) - for _ in range(n_live): - new_col.append(default) + n_phys = self._resolve_last_pos() + filler = self._varlen_filler(spec, default) + if values is None: + new_col.extend([filler] * n_phys) + elif n_live == n_phys: + new_col.extend(values) + else: + padded = [filler] * n_phys + live_pos = np.flatnonzero(self._valid_rows[:n_phys]) + for pos, value in zip(live_pos, values, strict=True): + padded[int(pos)] = value + new_col.extend(padded) new_col.flush() - elif self._is_list_column(compiled_col): - raise TypeError( - "add_column() does not support list columns; use the constructor with a full schema." - ) else: if default is not MISSING: try: @@ -9055,7 +9089,14 @@ def add_column( # noqa: C901 dparams=col_storage.get("dparams"), ) if n_live > 0: - if self._is_ndarray_column(compiled_col): + if values is not None: + # No holes (the common case) means the live rows are the + # leading slots, so a contiguous write beats a scatter. + if n_live == self._resolve_last_pos(): + new_col[:n_live] = values + else: + new_col[np.where(self._valid_rows[:])[0]] = values + elif self._is_ndarray_column(compiled_col): new_col[self._valid_rows] = np.broadcast_to(default_val, (n_live, *spec.item_shape)) else: new_col[self._valid_rows] = default_val @@ -9074,6 +9115,56 @@ def add_column( # noqa: C901 if isinstance(self._storage, FileTableStorage): self._storage.save_schema(self._schema_dict_with_computed()) + @staticmethod + def _varlen_filler(spec, default): + """Value written into the dead slots of a freshly added varlen column. + + Never read back -- the table only ever indexes live positions -- so it + just has to be something the spec accepts. + """ + if default is not MISSING: + return default + null_value = getattr(spec, "null_value", None) + if null_value is not None: + return null_value + if isinstance(spec, VLBytesSpec): + return b"" + if isinstance(spec, (Utf8Spec, VLStringSpec)): + return "" + return None + + def _add_column_values(self, name: str, col: CompiledColumn, values, n_live: int): + """Validate and coerce the ``values=`` argument of :meth:`add_column`. + + Returns a list for varlen scalar columns (which are fed row by row) and + a dtype-coerced ndarray for the fixed-width ones. + """ + if self._is_varlen_scalar_column(col): + values = list(values) + if len(values) != n_live: + raise ValueError( + f"add_column() values= for {name!r} requires {n_live} entries " + f"(live rows), got {len(values)}." + ) + return values + + arr = values[:] if isinstance(values, blosc2.NDArray) else np.asarray(values) + if len(arr) != n_live: + raise ValueError( + f"add_column() values= for {name!r} requires {n_live} entries (live rows), got {len(arr)}." + ) + expected = (n_live, *col.spec.item_shape) if self._is_ndarray_column(col) else (n_live,) + if arr.shape != expected: + raise ValueError( + f"add_column() values= for {name!r} must have shape {expected}, got {arr.shape}." + ) + try: + return arr.astype(col.spec.dtype) + except (ValueError, OverflowError) as exc: + raise TypeError( + f"Cannot coerce values= for {name!r} to dtype {col.spec.dtype!r}: {exc}" + ) from exc + def drop_column(self, name: str) -> None: """Remove a column from the table. diff --git a/tests/ctable/test_schema_mutations.py b/tests/ctable/test_schema_mutations.py index b8e7f99bf..9213a42cd 100644 --- a/tests/ctable/test_schema_mutations.py +++ b/tests/ctable/test_schema_mutations.py @@ -342,6 +342,115 @@ def test_add_column_skips_deleted_rows(): assert all(v == 3.0 for v in vals) +# =========================================================================== +# add_column(values=) +# =========================================================================== + + +def test_add_column_values_fills_live_rows(): + t = CTable(Row, new_data=DATA10) + t.add_column("weight", blosc2.float64(), values=np.arange(10, dtype=np.float64)) + np.testing.assert_array_equal(t["weight"][:], np.arange(10, dtype=np.float64)) + + +def test_add_column_values_needs_no_default(): + """values= is the second way to satisfy a non-empty table.""" + t = CTable(Row, new_data=DATA10) + t.add_column("weight", blosc2.float64(), values=[1.0] * 10) + assert t["weight"][0] == pytest.approx(1.0) + + +def test_add_column_values_coerced_to_spec_dtype(): + t = CTable(Row, new_data=DATA10) + t.add_column("n", blosc2.int8(), values=list(range(10))) + assert t["n"][:].dtype == np.int8 + + +def test_add_column_values_wrong_length_raises(): + t = CTable(Row, new_data=DATA10) + with pytest.raises(ValueError, match="requires 10 entries"): + t.add_column("weight", blosc2.float64(), values=[1.0, 2.0]) + + +def test_add_column_values_uncoercible_raises(): + t = CTable(Row, new_data=DATA10) + with pytest.raises(TypeError, match="Cannot coerce values="): + t.add_column("n", blosc2.int8(), values=["nope"] * 10) + + +def test_add_column_values_skips_deleted_rows(): + """values= is positional over *live* rows, not physical slots.""" + t = CTable(Row, new_data=DATA10) + t.delete([0, 1]) # 8 live rows + t.add_column("weight", blosc2.float64(), values=np.arange(8, dtype=np.float64)) + np.testing.assert_array_equal(t["weight"][:], np.arange(8, dtype=np.float64)) + np.testing.assert_array_equal(t["id"][:], np.arange(2, 10)) + + +def test_add_column_values_keeps_default_for_later_rows(): + t = CTable(Row, new_data=DATA10) + t.add_column("weight", blosc2.field(blosc2.float64(), default=9.0), values=[1.0] * 10) + t.append((10, 0.0, True, 0.0)) + np.testing.assert_array_equal(t["weight"][:], [*([1.0] * 10), 0.0]) + + +def test_add_column_values_ndarray_column(): + t = CTable(Row, new_data=DATA10) + vals = np.arange(20, dtype=np.float32).reshape(10, 2) + t.add_column("v", blosc2.ndarray((2,), np.float32), values=vals) + np.testing.assert_array_equal(t["v"][:], vals) + + +def test_add_column_values_ndarray_bad_shape_raises(): + t = CTable(Row, new_data=DATA10) + with pytest.raises(ValueError, match=r"must have shape \(10, 2\)"): + t.add_column("v", blosc2.ndarray((2,), np.float32), values=np.zeros(10, dtype=np.float32)) + + +def test_add_column_values_persists_on_disk(): + path = table_path("add_col_values") + t = CTable(Row, urlpath=path, mode="w", new_data=DATA10) + t.add_column("weight", blosc2.float64(), values=np.arange(10, dtype=np.float64)) + t.close() + t2 = CTable.open(path) + np.testing.assert_array_equal(t2["weight"][:], np.arange(10, dtype=np.float64)) + + +def test_add_column_values_vlstring(): + t = CTable(Row, new_data=DATA10) + vals = [f"s{i}" for i in range(10)] + t.add_column("s", blosc2.vlstring(), values=vals) + assert list(t["s"][:]) == vals + + +def test_add_column_values_vlstring_skips_deleted_rows(): + """Varlen columns are indexed physically, so the dead slots need filling too.""" + t = CTable(Row, new_data=DATA10) + t.delete([0, 1]) + vals = [f"s{i}" for i in range(8)] + t.add_column("s", blosc2.vlstring(), values=vals) + assert list(t["s"][:]) == vals + + +def test_add_column_default_vlstring_skips_deleted_rows(): + t = CTable(Row, new_data=DATA10) + t.delete([0, 1]) + t.add_column("s", blosc2.field(blosc2.vlstring(), default="z")) + assert list(t["s"][:]) == ["z"] * 8 + + +def test_add_column_values_list_column_raises(): + t = CTable(Row, new_data=DATA10) + with pytest.raises(TypeError, match="does not support list columns"): + t.add_column("l", blosc2.list(blosc2.int64()), values=[[1]] * 10) + + +def test_add_column_values_dictionary_column_raises(): + t = CTable(Row, new_data=DATA10) + with pytest.raises(TypeError, match="does not support dictionary columns"): + t.add_column("c", blosc2.dictionary(), values=["a"] * 10) + + # =========================================================================== # drop_column # =========================================================================== diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index d319b03dd..795c811e7 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -509,6 +509,39 @@ def test_ctable_utf8_add_and_drop_column(): assert "note" not in t.col_names +def test_ctable_utf8_add_column_values(): + t = make_table(["a", "b", "c"]) + t.add_column("note", blosc2.utf8(), values=["x", "yy", "zzz"]) + assert list(t["note"][:]) == ["x", "yy", "zzz"] + + +def test_ctable_utf8_add_column_values_from_computed_expression(): + """The documented round trip: compute on Date: Wed, 29 Jul 2026 13:18:30 +0200 Subject: [PATCH 57/86] Publish the utf8 <-> fixed-width conversion pair utf8 columns store and filter text compactly, but expressions that *return* strings need miniexpr's compile-time output width, which a variable-length column has not got. That rule was real but undocumented, and the conversion it implies was a sequence of incantations rather than an API. - blosc2.from_utf8() / blosc2.to_utf8(), and Utf8Array.astype() as the same conversion in method form. from_utf8() sizes the result to the longest value in codepoints, counted from the raw bytes (a UTF-8 codepoint starts at every non-continuation byte) so no row is decoded to size it, nothing truncates, and non-ASCII text does not over-allocate the 3-4x a byte-length bound would. Spans whose byte lengths cannot beat the running best are skipped without reading data at all, and all-ASCII spans settle from the offsets, so inference costs ~2 ms per 500k rows over the copy itself. - Column.assign() on utf8/vlstring/vlbytes/struct/object columns, which previously raised "Utf8Array assignment index must be int" and left no public way to overwrite a variable-length column. These rewrite whole rather than row by row: one write per backing batch, where the row-wise loop would rewrite an entire batch per row. _ScalarVarLenArray grows the set_all() that Utf8Array already had. - The rule itself, written down in the CTable reference, with the compute row added to the string-flavour comparison table. The doc snippets drop the blosc2.asarray() wrapper the earlier draft carried: lazyexpr takes a plain numpy operand, and wrapping one that is already in memory only adds a compression round trip (36.2 -> 30.1 ms per 1M rows). Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 16 +++ doc/reference/ctable.rst | 57 +++++++++- src/blosc2/__init__.py | 5 +- src/blosc2/_utf8_array.py | 154 ++++++++++++++++++++++++++ src/blosc2/ctable.py | 32 +++++- src/blosc2/scalar_array.py | 16 +++ tests/ctable/test_utf8.py | 145 +++++++++++++++++++++++- tests/ctable/test_vlstring_vlbytes.py | 55 +++++++++ 8 files changed, 476 insertions(+), 4 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 7ee057c6b..0770ef504 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -101,6 +101,22 @@ XXX version-specific blurb XXX evaluated on fixed-width arrays, and the result previously had to be written through the private `t._cols[name].set_all(...)`. A declared default is still honoured for rows appended later, so the two can be combined. +- **`blosc2.from_utf8()` / `blosc2.to_utf8()` and `Utf8Array.astype()`** make + the conversion between variable-length and fixed-width text an explicit, + documented pair. utf8 columns store and filter text compactly, but + string-*returning* expressions need miniexpr's compile-time output width, so + they run on fixed-width arrays; the rule is now written down (see "Computing + strings on a utf8 column" in the CTable reference) rather than left for + callers to discover. `from_utf8()` sizes the result to the longest value in + **codepoints**, counted from the raw bytes without decoding a row, so nothing + truncates and non-ASCII text does not over-allocate the 3-4x a byte-length + bound would. +- **`Column.assign()` works on utf8, vlstring, vlbytes, struct and object + columns.** It previously raised `TypeError: Utf8Array assignment index must + be int`, leaving no public way to overwrite a variable-length column's + values. These are now rewritten whole (one write per backing batch) rather + than row by row, which for the batched varlen columns would have rewritten a + whole batch per row. ### Improvements diff --git a/doc/reference/ctable.rst b/doc/reference/ctable.rst index 4428a3113..6a8b145de 100644 --- a/doc/reference/ctable.rst +++ b/doc/reference/ctable.rst @@ -470,7 +470,7 @@ evaluated on fixed-width arrays, so the result is written back explicitly rather than by :meth:`CTable.add_computed_column`:: arr = t["name"][:].astype(" fixed-width conversion + "from_utf8", + "to_utf8", # Grouped reductions "group_reduce", # Classes diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index 9f49ff0b6..55769dd04 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -920,6 +920,87 @@ def arrow_slice(self, pa, a: int, b: int, null_value: str | None = None): n, pa.py_buffer(rel), pa.py_buffer(data), validity, null_count ) + def _max_char_len(self, *, span_rows: int = UTF8_EXPR_SPAN) -> int: + """Longest value in codepoints, without decoding a row. + + A UTF-8 codepoint starts at every byte that is *not* a continuation + byte (``0b10xxxxxx``), so counting those over a row's byte range gives + its length in characters -- the width a fixed-width ``U`` array needs. + Byte lengths alone would only bound it, over-allocating up to 4x on + non-ASCII text. + + Byte lengths come from the offsets, so a span whose longest row cannot + beat the running best is skipped without reading any data at all, and + an all-ASCII span settles from the offsets alone. + """ + self.flush() + n = len(self) + widest = 0 + for start in range(0, n, span_rows): + stop = min(start + span_rows, n) + offs = np.asarray(self._offsets[start : stop + 1], dtype=np.int64) + byte_max = int(np.diff(offs).max(initial=0)) + if byte_max <= widest: + continue # bytes bound codepoints, so this span cannot win + raw = np.asarray(self._data[offs[0] : offs[-1]], dtype=np.uint8) + if not (raw & 0x80).any(): + widest = byte_max # pure ASCII: one byte per codepoint + continue + # Cumulative sums rather than reduceat: empty rows repeat an offset, + # which reduceat reads as "to the end" instead of as a zero count. + cumulative = np.concatenate(([0], np.cumsum((raw & 0xC0) != 0x80))) + rel = offs - offs[0] + widest = max(widest, int((cumulative[rel[1:]] - cumulative[rel[:-1]]).max(initial=0))) + return widest + + def astype(self, dtype=None, *, span_rows: int = UTF8_EXPR_SPAN) -> np.ndarray: + """Materialize as a fixed-width NumPy ``U`` array. + + This is the conversion half of the rule utf8 columns follow for + compute: they store and filter as variable-length text, and + string-returning expressions run on fixed-width arrays. See + :func:`blosc2.to_utf8` for the way back. + + Parameters + ---------- + dtype: + Target dtype. ``None`` or an unsized ``">> import blosc2 + >>> arr = blosc2.utf8_array(["hello", "café", "日本語"]) + >>> arr.astype().dtype + dtype('U", "=U")): + dtype = np.dtype(f" Utf8Array: """Return an in-memory copy.""" if spec is None: @@ -962,6 +1043,79 @@ def utf8_array(seq, spec=None, **kwargs) -> Utf8Array: return arr +def from_utf8(arr, dtype=None) -> np.ndarray: + """Convert variable-length UTF-8 text to a fixed-width NumPy ``U`` array. + + The outbound half of the utf8 compute rule: utf8 stores and filters text + compactly, while string-returning expressions and DSL kernels run on + fixed-width arrays. :func:`to_utf8` is the way back. + + Parameters + ---------- + arr: + A :class:`Utf8Array`, a utf8 :class:`~blosc2.CTable` column, a NumPy + ``StringDType`` array, or any iterable of ``str``. + dtype: + Target dtype. ``None`` (or an unsized ``">> import blosc2 + >>> arr = blosc2.utf8_array(["hello", "café"]) + >>> fixed = blosc2.from_utf8(arr) + >>> fixed.dtype + dtype('>> blosc2.to_utf8(fixed)[1] + 'café' + """ + raw = getattr(arr, "raw", arr) # a CTable Column exposes its container here + if isinstance(raw, Utf8Array): + return raw.astype(dtype) + values = np.asarray(raw if isinstance(raw, np.ndarray) else list(raw)) + if dtype is None or (isinstance(dtype, str) and dtype in ("U", "U", "=U")): + if values.dtype.kind == "U": + return values + dtype = np.dtype(f" Utf8Array: + """Build a :class:`Utf8Array` from fixed-width or otherwise decoded strings. + + The inbound half of the pair described in :func:`from_utf8`, and the way a + computed string result becomes storable again:: + + fixed = blosc2.from_utf8(t["name"]) + res = blosc2.lazyexpr("'x=' + a", {"a": fixed}).compute()[:] + t.add_column("prefixed", blosc2.utf8(), values=blosc2.to_utf8(res)) + + Parameters + ---------- + values: + NumPy ``U``/``StringDType`` array, or any iterable of ``str`` (or + ``None`` for a nullable *spec*). + spec: + The :class:`~blosc2.schema.Utf8Spec` describing the result. Defaults + to ``blosc2.utf8()`` (non-nullable). + + Returns + ------- + Utf8Array + """ + if isinstance(values, np.ndarray): + # tolist() yields plain str, which is Utf8Array.extend's fast path; + # iterating the array yields np.str_, which is not. + values = values.tolist() + return utf8_array(values, spec) + + class Utf8Factorizer: """Incremental factorizer over a :class:`Utf8Array`'s rows. diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index b47d37de1..24d822be6 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -2540,6 +2540,12 @@ def assign(self, data) -> None: root._mark_generated_columns_stale(self._col_name) root._mark_all_indexes_stale() return + if self.is_varlen_scalar: + self._assign_varlen_scalar(data) + root = self._table._root_table + root._mark_generated_columns_stale(self._col_name) + root._mark_all_indexes_stale() + return n_live = len(self) arr = np.asarray(data) if len(arr) != n_live: @@ -2554,6 +2560,30 @@ def assign(self, data) -> None: root._mark_generated_columns_stale(self._col_name) root._mark_all_indexes_stale() + def _assign_varlen_scalar(self, data) -> None: + """``assign()`` for utf8/vlstring/vlbytes/struct/object columns. + + These are rewritten whole rather than row by row: overwriting one row + of a utf8 column shifts every later offset, and one row of a batched + varlen column rewrites its whole batch, so the loop would be + quadratic. Dead slots keep their current contents. + """ + values = list(data) + n_live = len(self) + if len(values) != n_live: + raise ValueError(f"assign() requires {n_live} values (live rows), got {len(values)}.") + raw = self._raw_col + raw.flush() + n_phys = len(raw) + if n_live == n_phys: + raw.set_all(values) + return + current = list(raw[:]) + live_pos = np.flatnonzero(self._valid_rows[:n_phys]) + for pos, value in zip(live_pos, values, strict=True): + current[int(pos)] = value + raw.set_all(current) + # ------------------------------------------------------------------ # Null sentinel support # ------------------------------------------------------------------ @@ -8982,7 +9012,7 @@ def add_column( # noqa: C901 used to fill the new column. This is the supported way to land a computed result back into the table:: - res = blosc2.lazyexpr("'x=' + a", {"a": blosc2.asarray(arr)}).compute() + res = blosc2.lazyexpr("'x=' + a", {"a": arr}).compute() t.add_column("out", blosc2.utf8(), values=res[:]) A declared default is still honoured for rows appended later, so diff --git a/src/blosc2/scalar_array.py b/src/blosc2/scalar_array.py index 9220e2cce..620ef0608 100644 --- a/src/blosc2/scalar_array.py +++ b/src/blosc2/scalar_array.py @@ -258,6 +258,22 @@ def flush(self) -> None: self._pending.clear() self._invalidate_prefix_cache() + def set_all(self, values: Iterable[Any]) -> None: + """Replace the whole content, keeping the current row count. + + Writes each backing batch exactly once, where the equivalent loop over + :meth:`__setitem__` would rewrite a whole batch per row. Mirrors + ``Utf8Array.set_all`` so callers can treat both the same way. + """ + coerced = [self._coerce(v) for v in values] + if len(coerced) != len(self): + raise ValueError(f"set_all() expects {len(self)} values, got {len(coerced)}.") + prefix = self._persisted_prefix_sums() + for batch_index in range(len(prefix) - 1): + self._backend[batch_index] = coerced[prefix[batch_index] : prefix[batch_index + 1]] + # Batch lengths are unchanged, so the prefix cache stays valid. + self._pending = coerced[self._persisted_row_count :] + # ------------------------------------------------------------------ # Public read interface # ------------------------------------------------------------------ diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index 795c811e7..17110f7eb 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -519,7 +519,7 @@ def test_ctable_utf8_add_column_values_from_computed_expression(): """The documented round trip: compute on "] + + +def test_from_utf8_accepts_array_column_and_iterables(): + values = ["hello", "café"] + arr = blosc2.utf8_array(values) + t = make_table(values) + + for source in (arr, t["name"], np.array(values, dtype=STRING_DTYPE), values): + out = blosc2.from_utf8(source) + assert out.dtype == np.dtype("" + + +def test_utf8_conversion_round_trip(): + values = ["", "a", "日本語のテキスト", "x" * 40, "🎉"] + arr = blosc2.utf8_array(values) + assert list(blosc2.to_utf8(blosc2.from_utf8(arr))[:]) == values + + +def test_utf8_conversion_round_trip_through_an_expression(): + """The documented compute rule, end to end.""" + t = make_table(["a", "bb", "ccc"]) + fixed = blosc2.from_utf8(t["name"]) + res = blosc2.lazyexpr("'x=' + a", {"a": fixed}).compute()[:] + t.add_column("prefixed", blosc2.utf8(), values=blosc2.to_utf8(res)) + assert list(t["prefixed"][:]) == ["x=a", "x=bb", "x=ccc"] + + +# --------------------------------------------------------------------------- +# Column.assign on utf8 +# --------------------------------------------------------------------------- + + +def test_ctable_utf8_column_assign(): + t = make_table(["a", "bb", "ccc"]) + t["name"].assign(["X", "YY", "ZZZ"]) + assert list(t["name"][:]) == ["X", "YY", "ZZZ"] + + +def test_ctable_utf8_column_assign_from_computed_result(): + t = make_table(["a", "bb"]) + fixed = blosc2.from_utf8(t["name"]) + res = blosc2.lazyexpr("a + '!'", {"a": fixed}).compute()[:] + t["name"].assign(res) + assert list(t["name"][:]) == ["a!", "bb!"] + + +def test_ctable_utf8_column_assign_skips_deleted_rows(): + t = make_table(["a", "b", "c", "d"]) + t.delete([0, 2]) + t["name"].assign(["P", "Q"]) + assert list(t["name"][:]) == ["P", "Q"] + assert list(t["x"][:]) == [1, 3] + + +def test_ctable_utf8_column_assign_wrong_length_raises(): + t = make_table(["a", "bb"]) + with pytest.raises(ValueError, match="requires 2 values"): + t["name"].assign(["only-one"]) + + +def test_ctable_utf8_column_assign_persists(tmp_path): + path = str(tmp_path / "utf8_assign.b2d") + t = make_table(["a", "bb"], urlpath=path, mode="w") + t["name"].assign(["hello", "wörld"]) + t.close() + t2 = CTable.open(path) + assert list(t2["name"][:]) == ["hello", "wörld"] diff --git a/tests/ctable/test_vlstring_vlbytes.py b/tests/ctable/test_vlstring_vlbytes.py index 8f3bb3665..768c1b8a2 100644 --- a/tests/ctable/test_vlstring_vlbytes.py +++ b/tests/ctable/test_vlstring_vlbytes.py @@ -642,3 +642,58 @@ def test_ctable_vlstring_repr(): # repr is now the tabular view (same as str); a small table shows no footer. assert r == str(ct) assert "id" in r.splitlines()[0] # column header present + + +# --------------------------------------------------------------------------- +# Column.assign +# --------------------------------------------------------------------------- + + +@dataclass +class SmallBatchRow: + text: str = blosc2.field(blosc2.vlstring(batch_rows=4)) + + +def test_ctable_vlstring_column_assign(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + ct["text"].assign([f"new-{i}" for i in range(len(ROWS))]) + assert list(ct["text"][:]) == [f"new-{i}" for i in range(len(ROWS))] + # the sibling column is untouched + assert ct["data"][0] == b"bin0" + + +def test_ctable_vlbytes_column_assign(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + ct["data"].assign([bytes([i]) for i in range(len(ROWS))]) + assert list(ct["data"][:]) == [bytes([i]) for i in range(len(ROWS))] + + +def test_ctable_vlstring_column_assign_skips_deleted_rows(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + ct.delete([1, 3]) + ct["text"].assign(["p", "q", "r"]) + assert list(ct["text"][:]) == ["p", "q", "r"] + assert list(ct["id"][:]) == [0, 2, 4] + + +def test_ctable_vlstring_column_assign_wrong_length_raises(): + ct = blosc2.CTable(VLRow, new_data=ROWS) + with pytest.raises(ValueError, match="requires 5 values"): + ct["text"].assign(["too", "few"]) + + +def test_ctable_vlstring_column_assign_spans_batches(): + """set_all() rewrites each backing batch once, so cross-batch rows must land.""" + n = 23 # several full batches of 4, plus a partial one + ct = blosc2.CTable(SmallBatchRow, new_data={"text": [f"v{i}" for i in range(n)]}) + ct["text"].assign([f"w{i}" for i in range(n)]) + assert list(ct["text"][:]) == [f"w{i}" for i in range(n)] + + +def test_ctable_vlstring_column_assign_persists(tmp_path): + path = str(tmp_path / "vl_assign.b2d") + ct = blosc2.CTable(VLRow, urlpath=path, mode="w", new_data=ROWS) + ct["text"].assign([f"new-{i}" for i in range(len(ROWS))]) + ct.close() + ct2 = blosc2.CTable.open(path) + assert list(ct2["text"][:]) == [f"new-{i}" for i in range(len(ROWS))] From 2b23b07c5220d368a3a45ba4c89db2b8a682a414 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 13:19:07 +0200 Subject: [PATCH 58/86] Record the conversion pair in the assessment Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 60 +++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index a404b9ecc..52bcace49 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -265,7 +265,9 @@ out = blosc2.utf8_array(list(res)) # to_utf8, works today The one genuinely missing piece is writing that result back as a table column: `add_column()` has no `values=` parameter, so today it takes `add_column("out", field(utf8(), default=""))` followed by the -private `_cols["out"].set_all(...)`. +private `_cols["out"].set_all(...)`. **Fixed in `f8af0714`** (see ⁸); the `blosc2.asarray()` in the +snippet above also turns out to be unnecessary — `lazyexpr` takes a plain numpy operand, and wrapping +one that is already in memory only adds a compression round trip (36.2 → 30.1 ms per 1 M rows). So the choice is not "parity vs. conversion" — it is **which rule do we publish**: @@ -290,9 +292,7 @@ inverts: 1. ~~**Fix what is wrong, not merely absent**~~ — **done**. `Utf8Array` comparisons (`3692673f`) and the bare-array `lazyexpr` fallback (G4, `0b486b07`). Both were silent-wrong results, and neither depended on which rule is chosen below. **No known silently-wrong utf8 path remains.** -2. **Publish the conversion pair.** `Utf8Array.astype(" Date: Wed, 29 Jul 2026 13:49:15 +0200 Subject: [PATCH 59/86] Fix index-summary min()/max(), and restrict rank indexes to kind=FULL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two silently-wrong reductions, both affecting every indexable dtype and neither utf8-specific, found while checking whether create_index supports all five kinds on utf8 the way it does on --- RELEASE_NOTES.md | 17 ++++ doc/reference/ctable.rst | 3 + plans/string-flavours-assessment.md | 144 ++++++++++++++++++++++++--- src/blosc2/ctable.py | 70 +++++++++++-- src/blosc2/ctable_indexing.py | 45 ++++++++- tests/ctable/test_ctable_indexing.py | 136 +++++++++++++++++++++++++ 6 files changed, 386 insertions(+), 29 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0770ef504..c7ba39f0c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -134,6 +134,23 @@ XXX version-specific blurb XXX ### Bug fixes +- **`min()`/`max()` read from a column index returned the wrong value.** Two + independent causes, both affecting every indexable dtype. The block summaries + cover the column's *physical* extent, so the capacity padding (zeros, empty + strings) was reduced along with the data and `min()` reported it — wrong on + any table whose row count is not exactly its slot capacity. And `delete()` + bumps a visibility epoch that nothing recorded, so deleted rows kept + contributing their values to the block they sat in. Whole blocks below the + live row count are still read from the sidecar; the block straddling the + boundary is now rescanned, and a deletion since the index was built makes the + shortcut stand down. +- **`create_index` on `utf8()` and `dictionary()` columns accepted any index + kind** and built one over the alphabetical ranks that no query would ever + consult — only `IndexKind.FULL` reaches a rank index. `kind` now defaults to + `FULL` for these two column kinds (`BUCKET` elsewhere, unchanged) and raises + `ValueError` when another kind is requested explicitly. Previously + `create_index("category")` on a dictionary column built an unused BUCKET + index by default. - **Comparison operators on `Utf8Array`, dictionary and varlen scalar columns** returned a plain `False`: none defined them, so `column == "value"` fell through to object identity. Silently wrong rather than an error. All now diff --git a/doc/reference/ctable.rst b/doc/reference/ctable.rst index 6a8b145de..a68510956 100644 --- a/doc/reference/ctable.rst +++ b/doc/reference/ctable.rst @@ -1072,6 +1072,9 @@ CTable offers four ways to store strings. As a quick decision path: ``startswith``/substring searches, which no index covers, and the ranks are frozen at build time: a value inserted ahead of existing ones invalidates all of them, so the index falls back to a full sort until rebuilt. + Only ``kind=IndexKind.FULL`` consults a rank index, so that is the default + for these two column kinds (elsewhere the default is ``BUCKET``) and any + other kind raises ``ValueError`` rather than building an unused index. .. [#utf8expr] utf8 columns support both the operator form ``t[t.name == "x"]`` and the string-expression form diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 52bcace49..46666a657 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -26,7 +26,7 @@ because two of the conclusions below originally rested on it. | `sum(where=…)` | ✓ | ✓ | ✓ | ✗ | ✗ | | `sort_by` | ✓ | ✓ | ✓ | ✓ | ✗ TypeError | | `group_by` | ✓ | ✓ | ✓ | ✓ | ✗ | -| `create_index` | ✓ all 5 kinds | ✓ all 5 kinds | ✓ rank, ordering + predicates ⁷ | ✓ rank, ordering + `==`/`!=` ⁷ | ✗ | +| `create_index` | ✓ all 5 kinds | ✓ all 5 kinds | ✓ rank, `FULL` only ⁷ ⁹ | ✓ rank, `FULL` only ⁷ ⁹ | ✗ | | **Compute (string-returning)** | | | | | | | `add_computed_column("'x='+c")` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | | `assign(new=…)` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | @@ -71,8 +71,9 @@ caching the code→value map that `_ensure_cache()` was already building the for `create_index` is the one place where the flavours are not merely *slower* or *less convenient* than each other — they are in different performance classes. All five index kinds -(`SUMMARY`/`BUCKET`/`PARTIAL`/`FULL`/`OPSI`) build on `string()`, `bytes()` and — since `b1bbc54e` — -`utf8()`. What follows was written when utf8 had no index at all; the ⁷ note records what changed. +(`SUMMARY`/`BUCKET`/`PARTIAL`/`FULL`/`OPSI`) work on `string()` and `bytes()`. `utf8()` gained an +index in `b1bbc54e`, and `dictionary()` has always had one, but both are **`FULL`-only** — see §⁹. +What follows was written when utf8 had no index at all; the ⁷ note records what changed. ### What a FULL index buys on `rank lookup rather than through plan_query, and both that path + # and the ordering path require kind="full". The other kinds build over + # the ranks without error and are then never consulted, so refuse them + # here rather than charge for an index nothing can use. + rank_spec = self._schema.columns_by_name[col_name].spec + if explicit_kind and kind_str != "full" and isinstance(rank_spec, (Utf8Spec, DictionarySpec)): + flavour = "utf8" if isinstance(rank_spec, Utf8Spec) else "dictionary" + raise ValueError( + f"Column {col_name!r} is a {flavour} column, which is indexed by alphabetical rank; " + f"only kind='full' consults that index, so kind={kind_str!r} would build but never " + "be used. Use kind='full'." + ) # utf8 columns: index the alphabetical rank of each row's value. There is # no stored code array to wrap lazily, so the ranks are materialized here # (int32, 4 B/row) and handed to the builder as an ordinary array. @@ -913,8 +949,9 @@ def create_index( # noqa: C901 _persist_utf8_vocab(full, utf8_rank_meta, utf8_vocab) full["utf8_rank"] = utf8_rank_meta - value_epoch, _ = self._storage.get_epoch_counters() + value_epoch, visibility_epoch = self._storage.get_epoch_counters() descriptor["built_value_epoch"] = value_epoch + descriptor["built_visibility_epoch"] = visibility_epoch if is_persistent: # Use column name as token so sibling columns in compact stores get @@ -1002,6 +1039,7 @@ def compact_index( finally: _PERSISTENT_INDEXES.pop(proxy_key, None) updated_desc["built_value_epoch"] = descriptor.get("built_value_epoch", 0) + updated_desc["built_visibility_epoch"] = descriptor.get("built_visibility_epoch") catalog[lookup_key] = updated_desc self._storage.save_index_catalog(catalog) self._invalidate_index_catalog_cache() @@ -1013,6 +1051,7 @@ def compact_index( token = descriptor["token"] updated_desc = _copy_descriptor(store["indexes"].get(token, descriptor)) updated_desc["built_value_epoch"] = descriptor.get("built_value_epoch", 0) + updated_desc["built_visibility_epoch"] = descriptor.get("built_visibility_epoch") catalog[lookup_key] = updated_desc self._storage.save_index_catalog(catalog) self._invalidate_index_catalog_cache() diff --git a/tests/ctable/test_ctable_indexing.py b/tests/ctable/test_ctable_indexing.py index e64da9487..3b9d1ee4f 100644 --- a/tests/ctable/test_ctable_indexing.py +++ b/tests/ctable/test_ctable_indexing.py @@ -1295,3 +1295,139 @@ def capture(exact_plan): # Whichever way the gate goes, the answer is the same as an unindexed scan. assert indexed == sorted(table(values, None).where(query)["c"][:].tolist()) + + +# --------------------------------------------------------------------------- +# Summary min()/max() shortcut: live rows only +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class MinMaxRow: + c: str = blosc2.field(blosc2.string(max_length=11)) + n: int = blosc2.field(blosc2.int64()) + f: float = blosc2.field(blosc2.float64()) + + +def _minmax_table(path, n, kind="summary"): + t = blosc2.CTable(MinMaxRow, urlpath=str(path), mode="w") + strings = [f"taxi-{i % 997:05d}" for i in range(n)] + t.extend({"c": strings, "n": np.arange(n) + 5, "f": (np.arange(n) + 5) * 1.5}) + if kind is not None: + for col in ("c", "n", "f"): + t.create_index(col, kind=kind) + return t, strings + + +# 16384 is the block length these columns get, so these straddle, exactly fill, +# and fall short of a block boundary respectively. +@pytest.mark.parametrize("n", [5, 1000, 16384, 16385, 100_000]) +def test_summary_minmax_ignores_capacity_padding(tmpdir, n): + """Padded slots hold 0/'' and must not be reported as the column minimum.""" + t, strings = _minmax_table(tmpdir / f"pad{n}.b2t", n) + assert len(t._valid_rows) > t._n_rows or n == len(t._valid_rows) # padding present + assert t["c"].min() == min(strings) + assert t["c"].max() == max(strings) + assert t["n"].min() == 5 + assert t["n"].max() == n + 4 + assert t["f"].min() == 7.5 + + +@pytest.mark.parametrize("n", [5, 1000, 16385, 100_000]) +def test_summary_minmax_matches_unindexed_scan(tmpdir, n): + indexed, _ = _minmax_table(tmpdir / f"i{n}.b2t", n) + scan, _ = _minmax_table(tmpdir / f"s{n}.b2t", n, kind=None) + for col in ("c", "n", "f"): + assert indexed[col].min() == scan[col].min() + assert indexed[col].max() == scan[col].max() + + +def test_summary_minmax_declines_after_delete(tmpdir): + """delete() leaves the index usable for queries but the deleted row still + sits in its block, so the summary shortcut must stand down.""" + t, _ = _minmax_table(tmpdir / "del.b2t", 100_000) + assert t["n"].min() == 5 + t.delete(0) # drop the unique minimum + assert t["n"].min() == 6 + t.delete(t._n_rows - 1) # drop the unique maximum (values now run 6..100003) + assert t["n"].max() == 100_003 + assert t["c"].min() == min(t["c"][:].tolist()) + + +def test_summary_minmax_shortcut_still_taken(tmpdir): + """The padding fix must not disable the shortcut on the common padded table.""" + t, _ = _minmax_table(tmpdir / "fast.b2t", 100_000) + assert t["n"]._summary_minmax_source() is not None + assert t["n"]._index_summary_minmax("min") is not NotImplemented + t.delete(0) + assert t["n"]._summary_minmax_source() is None + + +def test_summary_minmax_nullable_nan_float(tmpdir): + """A NaN-sentinel float is the one nullable column the shortcut accepts; + padding is 0.0 there, which is not NaN and would pass as a real value.""" + + @dataclasses.dataclass + class NanRow: + f: float = blosc2.field(blosc2.float64(nullable=True, null_value=float("nan"))) + + t = blosc2.CTable(NanRow, urlpath=str(tmpdir / "nan.b2t"), mode="w") + vals = (np.arange(50_000) + 5) * 1.5 + vals[:10] = np.nan # leading nulls + t.extend({"f": vals}) + t.create_index("f", kind="summary") + assert t["f"].min() == np.nanmin(vals) + assert t["f"].max() == np.nanmax(vals) + + +# --------------------------------------------------------------------------- +# Rank-indexed flavours accept kind="full" only +# --------------------------------------------------------------------------- + + +@dataclasses.dataclass +class Utf8Row: + c: str = blosc2.field(blosc2.utf8()) + + +@dataclasses.dataclass +class DictRow: + c: str = blosc2.field(blosc2.dictionary()) + + +@pytest.mark.parametrize(("row_cls", "flavour"), [(Utf8Row, "utf8"), (DictRow, "dictionary")]) +@pytest.mark.parametrize("kind", ["summary", "bucket", "partial", "opsi"]) +def test_rank_index_rejects_non_full_kind(tmpdir, row_cls, flavour, kind): + """These build over the int32 ranks without error and are then never + consulted, so they must be refused rather than silently useless.""" + t = blosc2.CTable(row_cls, urlpath=str(tmpdir / f"{flavour}_{kind}.b2t"), mode="w") + t.extend({"c": [f"v{i % 50:03d}" for i in range(2000)]}) + with pytest.raises(ValueError, match=f"{flavour} column.*kind='full'"): + t.create_index("c", kind=kind) + assert "c" not in t._get_index_catalog() + + +@pytest.mark.parametrize(("row_cls", "flavour"), [(Utf8Row, "utf8"), (DictRow, "dictionary")]) +def test_rank_index_accepts_full_kind(tmpdir, row_cls, flavour): + t = blosc2.CTable(row_cls, urlpath=str(tmpdir / f"{flavour}_full.b2t"), mode="w") + values = [f"v{i % 50:03d}" for i in range(2000)] + t.extend({"c": values}) + t.create_index("c", kind="full") + assert t._get_index_catalog()["c"]["kind"] == "full" + # and it still answers correctly through the rank path + assert sorted(t[t["c"] == "v007"]["c"][:]) == [v for v in values if v == "v007"] + + +@pytest.mark.parametrize(("row_cls", "flavour"), [(Utf8Row, "utf8"), (DictRow, "dictionary")]) +def test_rank_index_default_kind_is_full(tmpdir, row_cls, flavour): + """The BUCKET default would hand these flavours an unusable index.""" + t = blosc2.CTable(row_cls, urlpath=str(tmpdir / f"{flavour}_def.b2t"), mode="w") + t.extend({"c": [f"v{i % 50:03d}" for i in range(2000)]}) + t.create_index("c") # no kind + assert t._get_index_catalog()["c"]["kind"] == "full" + + +def test_default_kind_unchanged_for_other_columns(tmpdir): + t = _make_table(200, persistent_path=str(tmpdir / "def.b2t")) + t.create_index("id") + assert t._get_index_catalog()["id"]["kind"] == "bucket" From 8e3868ba3950c489dfd3489c964622a77455f1e7 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 14:12:17 +0200 Subject: [PATCH 60/86] Make the utf8 compute refusals route instead of just refusing The rule (utf8 stores and filters; fixed-width computes) is deliberate, so an error on that boundary should hand back the conversion rather than stop at "not supported". Every refusal now names the column and prints the three lines that do work, echoing the user's own expression where there is one: Column 'name' is a variable-length utf8 column; string expressions that reference one are not supported here. utf8 stores and filters; fixed-width computes. Convert, compute, write back: fixed = blosc2.from_utf8(t['name']) res = blosc2.lazyexpr('upper(name)', {'name': fixed}).compute()[:] t.add_column('out', blosc2.utf8(), values=blosc2.to_utf8(res)) # or t['name'].assign(res) See 'Computing strings on a utf8 column' in the CTable reference docs. Two of those paths did not raise anything useful. t.apply(kernel) surfaced NumPy's DTypePromotionError from an internal result_type() over the operand dtypes, and lazyudf() over a utf8 column or bare Utf8Array surfaced "ValueError: malformed node or string ... StringDType()" from the NDArray dtype round-trip. Neither named the column, and neither said what to do. And one was worse than an error: add_computed_column(name, kernel, inputs=["utf8_col"]) was *accepted*, after which every read of that column -- and str(table), so the whole table -- raised that same malformed-node ValueError. It is the utf8 operand that cannot work, whatever the kernel returns (a bool-returning kernel fails identically), so the guard is on the dependencies and it fires at registration, while the table is still untouched. The section the messages point at is new too, along with a compute row in the string-flavour comparison table. Its anchor is renamed to ComputingUtf8Strings: as _Utf8Compute: it collided with the [#utf8compute] footnote label, which docutils normalizes to the same target name. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 13 +++++ doc/reference/ctable.rst | 25 ++++++--- src/blosc2/_utf8_array.py | 23 ++++++++ src/blosc2/ctable.py | 43 ++++++++++++++- src/blosc2/lazyexpr.py | 34 ++++++++++++ tests/ctable/test_utf8.py | 113 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 239 insertions(+), 12 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c7ba39f0c..f551fdad5 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -131,9 +131,22 @@ XXX version-specific blurb XXX the cost is paid in blocks — a mask selecting 21% of buckets could touch 96% of them. Affected every indexable dtype; the relative penalty was worst on numerics (`float64` 6.3 ms -> 77.9 ms before, 6.6 ms after). +- **The utf8 compute refusals now route instead of merely refusing.** Every + path that cannot take a utf8 column — `add_computed_column`, + `add_generated_column`, `assign`, `apply`, `lazyudf`, with a string + expression or a DSL kernel — raises `NotImplementedError` naming the column + and printing the three-line conversion, echoing the user's own expression + where there is one. Two of those paths previously failed with a raw NumPy + `DTypePromotionError` and a `ValueError: malformed node or string ... + StringDType()`, neither of which named the column or the fix. ### Bug fixes +- **A DSL kernel over a utf8 column registered as a computed column, then + broke the table.** `add_computed_column(name, kernel, inputs=["utf8_col"])` + was accepted, after which every read of that column *and* `str(table)` + raised `ValueError: malformed node or string`. The kernel is now refused at + registration, where the table is still untouched. - **`min()`/`max()` read from a column index returned the wrong value.** Two independent causes, both affecting every indexable dtype. The block summaries cover the column's *physical* extent, so the capacity padding (zeros, empty diff --git a/doc/reference/ctable.rst b/doc/reference/ctable.rst index a68510956..ab8a579ab 100644 --- a/doc/reference/ctable.rst +++ b/doc/reference/ctable.rst @@ -1088,14 +1088,14 @@ CTable offers four ways to store strings. As a quick decision path: .. [#utf8compute] Expressions that *return* strings — concatenation, ``upper``, ``replace``, DSL kernels — run on fixed-width arrays, so a utf8 column is converted first and the result written back. See - :ref:`Utf8Compute` below. + :ref:`ComputingUtf8Strings` below. Note that a plain ``str`` annotation without an explicit :func:`field` spec still maps to fixed-width ``string(max_length=32)`` for backward compatibility; opt in to variable-length storage with ``blosc2.field(blosc2.utf8())``. -.. _Utf8Compute: +.. _ComputingUtf8Strings: Computing strings on a utf8 column ---------------------------------- @@ -1127,13 +1127,20 @@ To overwrite an existing column rather than add one, use Both write paths take one value per **live** row, so rows removed by :meth:`CTable.delete` are skipped. -:meth:`CTable.add_computed_column` and :meth:`CTable.assign` do **not** accept -string-returning expressions over utf8 columns; they raise -``NotImplementedError`` rather than convert behind your back, because the -conversion's cost — a decode plus a widening copy — is worth being visible. -Passing a :func:`blosc2.dsl_kernel` that returns strings over a utf8 column is -not supported either, and currently fails with a NumPy dtype-promotion error -rather than a clear one. +The compute surface refuses a utf8 column rather than converting behind your +back, because the conversion's cost — a decode plus a widening copy — is worth +being visible. Every one of those refusals raises ``NotImplementedError`` +naming the column and printing the recipe above: + +* :meth:`CTable.add_computed_column`, :meth:`CTable.add_generated_column` and + :meth:`CTable.assign` with a string expression that references a utf8 column; +* the same three with a :func:`blosc2.dsl_kernel`, plus :meth:`CTable.apply` + and :func:`blosc2.lazyudf` — note it is the utf8 **operand** that cannot + work, whatever the kernel returns, so a kernel producing a number or a + boolean is refused just the same. + +Only :func:`blosc2.lazyexpr` accepts a utf8 operand directly: it routes to the +span driver and returns a :class:`Utf8Array`, evaluating span by span. Array, encoded, and compound specs ---------------------------------- diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index 55769dd04..35d4453ab 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -75,6 +75,29 @@ UTF8_EXPR_BUDGET = 64 << 20 +def utf8_compute_error(lead: str, *, source: str, compute: str, assignable: bool = True) -> str: + """Message for every "utf8 does not compute here" refusal. + + The rule (utf8 stores and filters; fixed-width computes) is deliberate, so + the error's job is to route rather than merely refuse: *lead* states what + was rejected, and the shared tail spells out the three-line conversion, + parameterized on how the caller got here. + """ + write_back = ( + f" t.add_column('out', blosc2.utf8(), values=blosc2.to_utf8(res)) # or {source}.assign(res)" + if assignable + else " out = blosc2.to_utf8(res)" + ) + return ( + f"{lead}\n" + "utf8 stores and filters; fixed-width computes. Convert, compute, write back:\n" + f" fixed = blosc2.from_utf8({source})\n" + f" res = {compute}\n" + f"{write_back}\n" + "See 'Computing strings on a utf8 column' in the CTable reference docs." + ) + + def utf8_span_dtype(span: np.ndarray) -> np.dtype: """Fixed-width ``U`` dtype wide enough for every value in *span*. diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 3f7e08e84..f8708f797 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -10203,6 +10203,31 @@ def _resolve_dsl_kernel(self, kernel, inputs) -> tuple[Any, list[str]]: self._validate_transformer_dep(d) return kernel, col_deps + def _guard_utf8_kernel_deps(self, col_deps) -> None: + """Refuse a UDF/DSL kernel that reads a utf8 column, naming the column. + + :func:`blosc2.lazyudf` refuses the operand on its own, but only ever + sees the container, so it cannot say *which* column. Called from the + column-registration paths as well, where the kernel would otherwise be + accepted and then fail on every read -- and on ``str(table)`` -- when + the output container is allocated from a ``StringDType`` the NDArray + dtype round-trip cannot parse. It is the utf8 *operand* that does + this, whatever the kernel returns. + """ + from blosc2._utf8_array import utf8_compute_error + + for dep in col_deps: + col = self._schema.columns_by_name.get(dep) + if col is not None and self._is_utf8_column(col): + raise NotImplementedError( + utf8_compute_error( + f"Column {dep!r} is a variable-length utf8 column and cannot be a UDF " + "or DSL kernel operand.", + source=f"t[{dep!r}]", + compute="blosc2.lazyudf(kernel, (fixed,)).compute()[:]", + ) + ) + def _normalize_transformer(self, expr, inputs=None) -> dict: """Resolve *expr* into a transformer descriptor. @@ -10218,6 +10243,7 @@ def _normalize_transformer(self, expr, inputs=None) -> dict: """ if isinstance(expr, blosc2.DSLKernel): kernel, col_deps = self._resolve_dsl_kernel(expr, inputs) + self._guard_utf8_kernel_deps(col_deps) return {"kind": "dsl", "kernel": kernel, "col_deps": col_deps} # Resolve a callable once (a lambda may return a LazyExpr or a LazyUDF). obj = expr(self._cols) if (callable(expr) and not isinstance(expr, blosc2.LazyExpr)) else expr @@ -10229,10 +10255,12 @@ def _normalize_transformer(self, expr, inputs=None) -> dict: kernel = obj.func if kernel.dsl_error is not None: raise blosc2.DSLSyntaxError(f"Invalid DSL kernel: {kernel.dsl_error}") + col_deps = self._dsl_deps_from_lazyudf(obj) + self._guard_utf8_kernel_deps(col_deps) return { "kind": "dsl", "kernel": kernel, - "col_deps": self._dsl_deps_from_lazyudf(obj), + "col_deps": col_deps, "jit_backend": obj.kwargs.get("jit_backend"), } lazy, col_deps = self._normalize_expression_transformer(obj) @@ -10471,6 +10499,9 @@ def apply( # inputs add_computed_column()/add_generated_column() pass to # lazyudf() for DSL/UDF columns -- so the live-row mask is applied # once, here, to the result rather than to every operand. + # lazyudf() refuses a utf8 operand too, but only sees the container, so + # settle it here where the column name is still known. + self._guard_utf8_kernel_deps(names) operands = tuple(self._cols[self._logical_to_physical_name(name)] for name in names) result = blosc2.lazyudf(func, operands, dtype=dtype, jit=jit).compute() return result[self._valid_rows] @@ -13035,6 +13066,8 @@ def _expression_references_name(expr: str, name: str) -> bool: return re.search(rf"(? None: + from blosc2._utf8_array import utf8_compute_error + for name, meta in self._root_table._materialized_cols.items(): if meta.get("stale", False) and self._expression_references_name(expr, name): raise ValueError( @@ -13052,8 +13085,12 @@ def _guard_scalar_expression(self, expr: str, *, allow_utf8: bool = False) -> No if allow_utf8: continue raise NotImplementedError( - f"Column {col.name!r} is a variable-length utf8 column; " - "string expressions on utf8 columns are not supported here." + utf8_compute_error( + f"Column {col.name!r} is a variable-length utf8 column; string expressions " + "that reference one are not supported here.", + source=f"t[{col.name!r}]", + compute=f"blosc2.lazyexpr({expr!r}, {{{col.name!r}: fixed}}).compute()[:]", + ) ) if self._is_varlen_scalar_column(col) and self._expression_references_name(expr, col.name): raise NotImplementedError( diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 81d57ec9c..9b4fa074b 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -4718,10 +4718,41 @@ def _dsl_kernel_string_dtype(func, inputs): return np.dtype(out) +def _guard_utf8_udf_inputs(inputs) -> None: + """Reject variable-length utf8 operands in a UDF, naming the conversion.""" + from blosc2._utf8_array import Utf8Array, utf8_compute_error + + for operand in inputs or (): + raw = getattr(operand, "raw", operand) # a CTable Column exposes its container here + if not isinstance(raw, Utf8Array): + continue + name = getattr(operand, "_col_name", None) + source = f"t[{name!r}]" if name else "arr" + raise NotImplementedError( + utf8_compute_error( + ( + f"Column {name!r} is a variable-length utf8 column and cannot be a UDF operand." + if name + else "A variable-length Utf8Array cannot be a UDF operand." + ), + source=source, + compute="blosc2.lazyudf(kernel, (fixed,)).compute()[:]", + assignable=name is not None, + ) + ) + + class LazyUDF(LazyArray): def __init__( self, func, inputs, dtype, shape=None, chunked_eval=True, jit=None, jit_backend=None, **kwargs ): + # A utf8 operand only duck-types as an array: convert_inputs() would wrap + # it in a SimpleProxy widened to a fixed "b" + + t = make_table(["a", "bb"]) + with pytest.raises(NotImplementedError, match="cannot be a UDF"): + t.add_computed_column("flag", is_long, inputs=["name"]) + + +def test_utf8_apply_names_the_column(): + t = make_table(["a", "bb"]) + with pytest.raises(NotImplementedError) as exc: + t.apply(_shout, columns=["name"]) + _assert_routes(str(exc.value), "t['name']") + + +def test_utf8_lazyudf_over_a_column_names_the_column(): + t = make_table(["a", "bb"]) + with pytest.raises(NotImplementedError) as exc: + blosc2.lazyudf(_shout, (t["name"],)) + _assert_routes(str(exc.value), "t['name']") + + +def test_utf8_lazyudf_over_a_bare_array_routes_too(): + arr = blosc2.utf8_array(["a", "bb"]) + with pytest.raises(NotImplementedError) as exc: + blosc2.lazyudf(_shout, (arr,)) + msg = str(exc.value) + assert "blosc2.from_utf8(arr)" in msg + assert "blosc2.to_utf8(" in msg + # No table to assign into, so the message must not suggest one. + assert ".assign(" not in msg + + +def test_utf8_refusal_recipe_actually_works(): + """The recipe the error prints must run as printed.""" + t = make_table(["a", "bb"]) + fixed = blosc2.from_utf8(t["name"]) + res = blosc2.lazyexpr("upper(name)", {"name": fixed}).compute()[:] + t.add_column("out", blosc2.utf8(), values=blosc2.to_utf8(res)) + assert list(t["out"][:]) == ["A", "BB"] + + res = blosc2.lazyudf(_shout, (fixed,)).compute()[:] + assert list(blosc2.to_utf8(res)[:]) == ["A", "BB"] + + +def test_non_utf8_dsl_kernel_column_still_works(): + """The guard must not catch ordinary columns.""" + + @blosc2.dsl_kernel + def double(x): + return x * 2 + + t = make_table(["a", "bb"]) + t.add_computed_column("dbl", double, inputs=["x"]) + np.testing.assert_array_equal(t["dbl"][:], [0, 2]) From c712c95aa6f2d17b99142dc29255ad33bb63431e Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 14:12:46 +0200 Subject: [PATCH 61/86] Record the error-message routing in the assessment Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 62 +++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 3 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 46666a657..91618bc6f 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -311,9 +311,7 @@ never rested on these two rows, which is exactly the point the next paragraph ma 15×/40× equality and range gap"* — proved **too pessimistic**: persisting the sorted vocabulary made a literal→rank lookup one `searchsorted`, so `==` went 29.00 → 5.49 ms and `<` 34.57 → 5.45 ms. It was inherited from the "ordering only" misreading corrected two sections above. -4. **Make the error messages route.** `add_computed_column` on utf8 currently says only "not - supported"; it should name the two-line workaround. That, more than parity, is what removes the - confusion you are objecting to. +4. ~~**Make the error messages route.**~~ — **done**, `8e3868ba`. See ¹⁰. 5. **Drop G2/G3/G5**, or park them behind a concrete user request. G2 in particular buys a `StringDType`-in-schema serialization hazard for a surface the conversion pair already covers. @@ -545,3 +543,61 @@ Also worth recording: `_summary_minmax_source` excludes utf8 via the `is_varlen_ a `SUMMARY` index on a utf8 column writes block extrema nothing will ever read. That is now moot — the kind is refused outright — but if utf8 `min()`/`max()` is ever wanted, the extrema are well-defined and the exclusion is the only thing in the way. + +--- + +## ¹⁰ The compute-side error messages — what shipped + +`8e3868ba`. Item 4 turned out to be two jobs, not one: most paths raised something clear but +unhelpful, two raised something *un*clear, and one did not raise at all. + +Every refusal now names the column and prints the recipe, echoing the user's own expression: + +``` +Column 'name' is a variable-length utf8 column; string expressions that reference one +are not supported here. +utf8 stores and filters; fixed-width computes. Convert, compute, write back: + fixed = blosc2.from_utf8(t['name']) + res = blosc2.lazyexpr('upper(name)', {'name': fixed}).compute()[:] + t.add_column('out', blosc2.utf8(), values=blosc2.to_utf8(res)) # or t['name'].assign(res) +See 'Computing strings on a utf8 column' in the CTable reference docs. +``` + +The full inventory, probed rather than assumed: + +| path | before | now | +|---|---|---| +| `add_computed_column("upper(c)")` | NotImpl, no route | routes | +| `assign(new="upper(c)")` | NotImpl, no route | routes | +| `add_generated_column(values="upper(c)")` | NotImpl, no route | routes | +| `add_computed_column(kernel, inputs=["c"])` | **accepted, then broke the table** | refused at registration | +| `t.apply(kernel)` | NumPy `DTypePromotionError` | routes, names the column | +| `lazyudf(kernel, (t["c"],))` | `ValueError: malformed node … StringDType()` | routes, names the column | +| `lazyudf(kernel, (utf8_array,))` | same | routes (no `.assign` line — no table) | +| `lazyexpr(expr, {"a": utf8_array})` | works (span driver, ²) | unchanged | + +Three things worth keeping: + +- **The `inputs=` route was a table-breaker, not just a bad message.** `add_computed_column(name, + kernel, inputs=["utf8_col"])` registered fine; afterwards every read of that column *and* + `str(table)` raised `ValueError: malformed node or string`, so the table could not even be + displayed. The guard is now on the kernel's dependencies and fires at registration, while the + table is still untouched. +- **It is the utf8 *operand* that cannot work, not the string output.** A kernel returning a bool + (`name > "b"`) fails identically — the operand is widened to a `SimpleProxy` and the output + container is allocated from a `StringDType` the NDArray dtype round-trip cannot parse. So the + guard is on inputs, and the docs say so; an earlier draft of this document implied the output + type was the problem. +- **`lazyudf()` needed the guard twice.** The `DTypePromotionError` fires in the `lazyudf()` + function's dtype inference, before `LazyUDF.__init__` runs, so guarding the constructor alone + left `t.apply()` untouched. Both now check; `apply` also guards at the CTable level, because + `lazyudf` only ever sees the container and cannot say *which* column. + +Each printed recipe was run verbatim before the message shipped. + +Also fixed here: the `.. _Utf8Compute:` anchor added in `5b31abe4` collided with the +`[#utf8compute]` footnote label — docutils normalizes both to the same target name, which cost the +footnote its reference. Renamed to `ComputingUtf8Strings`. + +With this, items 1–4 of the priority list are done and only item 5 (drop G2/G3/G5, a decision +rather than work) remains. From 93cea90a0dbc19c67b59d6d73c4edf31859560f2 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 14:13:07 +0200 Subject: [PATCH 62/86] Refresh the capability matrix for the routed errors Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 91618bc6f..94b801b2a 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -28,9 +28,9 @@ because two of the conclusions below originally rested on it. | `group_by` | ✓ | ✓ | ✓ | ✓ | ✗ | | `create_index` | ✓ all 5 kinds | ✓ all 5 kinds | ✓ rank, `FULL` only ⁷ ⁹ | ✓ rank, `FULL` only ⁷ ⁹ | ✗ | | **Compute (string-returning)** | | | | | | -| `add_computed_column("'x='+c")` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | -| `assign(new=…)` | ✓ | ✓ | **✗ NotImpl** | ✗ | ✗ | -| `t.apply(dsl_kernel)` / `lazyudf` | ✓ | ✓ | **✗ ValueError** ¹ | ✗ | ✗ RuntimeError | +| `add_computed_column("'x='+c")` | ✓ | ✓ | ✗ NotImpl, routes ¹⁰ | ✗ | ✗ | +| `assign(new=…)` | ✓ | ✓ | ✗ NotImpl, routes ¹⁰ | ✗ | ✗ | +| `t.apply(dsl_kernel)` / `lazyudf` | ✓ | ✓ | ✗ NotImpl, routes ¹ ¹⁰ | ✗ | ✗ RuntimeError | | nested (dotted) leaf in expr | ✓ | ✓ | ✗ NotImpl | ✗ | ✗ | | **Bare container (no CTable)** | | | | | | | `lazyexpr(expr, {a: col})` | ✓ NDArray | ✓ | ✓ span driver, returns `Utf8Array` ² | ⚠ padded ⁶ | ⚠ numpy | @@ -40,9 +40,10 @@ because two of the conclusions below originally rested on it. | save + reopen | ✓ | ✓ | ✓ | ✓ | ✓ | | NumPy requirement | any | any | ≥ 2.0 | any | any | -¹ `ValueError: malformed node or string … StringDType()` — `lazyudf` tries to allocate an NDArray +¹ Was `ValueError: malformed node or string … StringDType()` — `lazyudf` allocates an NDArray output with `dtype=StringDType()`, which `NDArray.dtype`'s `ast.literal_eval` round-trip cannot -parse (`blosc2_ext.pyx:3818`). +parse (`blosc2_ext.pyx:3818`). The underlying limit stands (that is G2, dropped); since `8e3868ba` +the operand is refused up front instead, with a message that names the conversion. See ¹⁰. ² Fixed in `0b486b07` — was correct values down the wrong path: a `SimpleProxy` widened the column to a fixed ` Date: Wed, 29 Jul 2026 14:41:14 +0200 Subject: [PATCH 63/86] Dispatch the array constructors on NumPy's StringDType blosc2 asked for its own constructor where NumPy asks for a dtype: blosc2.asarray(np.array([...], dtype=StringDType())) raised "data type 'StringDType()' not understood", and blosc2.zeros(n, dtype=StringDType()) a "malformed node" ValueError from the NDArray dtype round-trip. Both now return a Utf8Array, as do empty(), ones() and full(), with the same fill values NumPy uses ('', '', '1', str(fill_value)). The dispatch is on the *target* dtype, not the input's, so asarray(utf8_source, dtype=" --- RELEASE_NOTES.md | 17 ++++++ doc/reference/ctable.rst | 35 ++++++++++++ src/blosc2/_utf8_array.py | 63 +++++++++++++++++++++ src/blosc2/ndarray.py | 76 ++++++++++++++++++++++++- src/blosc2/proxy.py | 12 +++- tests/ctable/test_utf8.py | 115 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 315 insertions(+), 3 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index f551fdad5..c0585f61b 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -111,6 +111,23 @@ XXX version-specific blurb XXX **codepoints**, counted from the raw bytes without decoding a row, so nothing truncates and non-ASCII text does not over-allocate the 3-4x a byte-length bound would. +- **The array constructors dispatch on NumPy's `StringDType`.** + `blosc2.asarray(np.array([...], dtype=StringDType()))` used to raise + `TypeError: data type 'StringDType()' not understood`, and + `blosc2.zeros(n, dtype=StringDType())` a `malformed node` `ValueError`; both + now return a `Utf8Array`, as do `empty`, `ones` and `full`, with the same + fill values NumPy uses (`''`, `''`, `'1'`, `str(fill_value)`). The dispatch + is on the *target* dtype, so `asarray(utf8_source, dtype=" Utf8Array + blosc2.zeros(3, dtype=StringDType()) # -> Utf8Array + blosc2.full(3, "x", dtype=StringDType()) # -> Utf8Array + +The fill values match NumPy's own (``''`` for ``zeros``/``empty``, ``'1'`` for +``ones``, ``str(fill_value)`` for ``full``), and the result satisfies the +:class:`blosc2.Array` protocol, so it can be used wherever a blosc2 array can. + +What blosc2 does **not** do is store ``StringDType`` in an +:class:`~blosc2.NDArray`, and it cannot: that dtype keeps each row's payload +outside the array buffer — a 100-character string still reports +``nbytes == 16`` — and supports no buffer protocol, so compressing the buffer +would persist pointers rather than text. A :class:`Utf8Array` holds the same +text as int64 offsets plus a UTF-8 blob, which is the layout Arrow uses for +``large_string`` and what makes :meth:`CTable.to_arrow` zero-copy. + +The dispatch is on the *target* dtype, so asking for a fixed width still gets +you a plain NDArray:: + + blosc2.asarray(utf8_source, dtype=" NDArray, fixed width + +Note the schema layer keeps its own vocabulary: ``blosc2.field()`` takes a +spec (:func:`utf8`, :func:`string`, :func:`int32`, ...) and not a raw NumPy +dtype, for any column type, because a spec also carries nullability, the null +sentinel, constraints and storage configuration. + .. _ComputingUtf8Strings: Computing strings on a utf8 column diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index 35d4453ab..4c7d60785 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -789,6 +789,32 @@ def dtype(self): """The ``StringDType`` used for materialized reads.""" return self._dtype + @property + def shape(self) -> tuple[int, ...]: + """Row count as a 1-D shape. Completes the :class:`blosc2.Array` protocol.""" + return (len(self),) + + @property + def ndim(self) -> int: + """Always 1: a utf8 array is a flat sequence of strings.""" + return 1 + + @property + def size(self) -> int: + """Number of rows, matching NumPy's ``size`` for a 1-D array.""" + return len(self) + + def __array__(self, dtype=None, copy=None) -> np.ndarray: + """Materialize for NumPy, keeping ``StringDType`` unless asked otherwise. + + Without this, ``np.asarray`` falls back to iterating the rows and infers + a fixed-width `` Utf8Array: return utf8_array(values, spec) +def is_string_dtype(dtype) -> bool: + """True for NumPy's variable-length ``StringDType`` (kind ``'T'``).""" + if dtype is None: + return False + try: + return np.dtype(dtype).kind == "T" + except TypeError: + # np.dtype() rejects StringDType passed as a class rather than instance. + return isinstance(dtype, type) and getattr(dtype, "kind", None) == "T" + + +def asarray_utf8(array, copy=None, **kwargs) -> Utf8Array: + """Back :func:`blosc2.asarray` when the *target* dtype is ``StringDType``. + + A ``StringDType`` array keeps its payload outside its own buffer (a 100 + character string still reports ``nbytes == 16``) and offers no buffer + protocol at all, so an :class:`~blosc2.NDArray` -- which compresses that + buffer -- cannot hold one: it would persist pointers. A + :class:`Utf8Array` holds the same text as offsets + UTF-8 bytes, the + layout Arrow uses for ``large_string``, so that is what this returns. + """ + if kwargs: + raise TypeError( + f"blosc2.asarray() does not accept {sorted(kwargs)!r} for variable-length " + "text; use blosc2.utf8_array(values, spec) to control its storage." + ) + ndim = getattr(array, "ndim", 1) + if ndim != 1: + raise ValueError( + f"Variable-length text is 1-D only, got a {ndim}-D array. Reshape it, or ask " + "for a fixed-width ' bool: + """True for NumPy's variable-length ``StringDType``; see ``_utf8_array``.""" + from blosc2._utf8_array import is_string_dtype + + return is_string_dtype(dtype) + + +def _asarray_string_dispatch(array, copy, kwargs): + """Route variable-length text out of :func:`asarray`'s NDArray path. + + Returns ``(result, array)``. *result* is a :class:`Utf8Array` when the + **target** dtype is NumPy's ``StringDType``, and ``None`` otherwise -- in + which case *array* comes back ready for the fixed-width path, so + ``asarray(utf8_source, dtype=" NDArray: """Create an empty array. @@ -5787,6 +5838,8 @@ def empty(shape: int | tuple | list, dtype: np.dtype | str | None = np.float64, >>> array.dtype dtype('int32') """ + if _is_string_dtype(dtype): + return _utf8_filled(shape, "", **kwargs) dtype = _check_dtype(dtype) shape = _check_shape(shape) kwargs = _check_ndarray_kwargs(**kwargs) @@ -5895,6 +5948,8 @@ def zeros(shape: int | tuple | list, dtype: np.dtype | str = np.float64, **kwarg >>> array.dtype dtype('float64') """ + if _is_string_dtype(dtype): + return _utf8_filled(shape, "", **kwargs) dtype = _check_dtype(dtype) shape = _check_shape(shape) kwargs = _check_ndarray_kwargs(**kwargs) @@ -5949,6 +6004,8 @@ def full( >>> array.dtype dtype('bool') """ + if _is_string_dtype(dtype): + return _utf8_filled(shape, str(fill_value), **kwargs) if isinstance(fill_value, bytes): dtype = np.dtype(f"S{len(fill_value)}") if dtype is None: @@ -6691,9 +6748,11 @@ def asarray(array: Sequence | blosc2.Array, copy: bool | None = None, **kwargs: Returns ------- - out: :ref:`NDArray` + out: :ref:`NDArray` or :class:`Utf8Array` A new :ref:`NDArray` made of :paramref:`array`, or the original - array when a copy is not required. + array when a copy is not required. When the target dtype is NumPy's + variable-length ``StringDType``, a :class:`Utf8Array` is returned + instead -- see the Notes. Notes ----- @@ -6702,6 +6761,16 @@ def asarray(array: Sequence | blosc2.Array, copy: bool | None = None, **kwargs: be used for ingesting e.g. disk or network based arrays very effectively and without consuming lots of memory. + ``StringDType`` cannot back an NDArray: it keeps each row's payload outside + the array buffer (a 100-character string still reports ``nbytes == 16``) + and offers no buffer protocol, so compressing that buffer would persist + pointers. Such input is therefore stored as a :class:`Utf8Array`, which + holds the same text as offsets + UTF-8 bytes -- the layout Arrow uses for + ``large_string``. The dispatch is on the *target* dtype, so + ``asarray(utf8_source, dtype=">> import blosc2 @@ -6718,6 +6787,9 @@ def asarray(array: Sequence | blosc2.Array, copy: bool | None = None, **kwargs: raise ValueError("Only unsafe casting is supported at the moment.") if not hasattr(array, "shape"): array = np.asarray(array) # defaults if dtype=None + utf8_out, array = _asarray_string_dispatch(array, copy, kwargs) + if utf8_out is not None: + return utf8_out dtype_ = blosc2.proxy.convert_dtype(array.dtype) dtype = blosc2.proxy.convert_dtype(kwargs.pop("dtype", dtype_)) # check if dtype provided kwargs = _check_ndarray_kwargs(**kwargs) diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index 73abb53b4..76727a1c2 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -673,7 +673,17 @@ class SimpleProxy(blosc2.Operand): """ def __init__(self, src, chunks: tuple | None = None, blocks: tuple | None = None): - if not hasattr(src, "shape") or not hasattr(src, "dtype"): + from blosc2._utf8_array import Utf8Array + + if isinstance(src, Utf8Array): + # The compute engine indexes chunk-wise into fixed-width elements, + # which a variable-length utf8 array has not got, so widen it here. + # (lazyexpr() routes utf8 operands to the span driver instead; this + # is the fallback for the entry points that do not.) Until this + # array grew a .shape, the branch below did the same thing by + # accident, via np.asarray. + src = src.astype() + elif not hasattr(src, "shape") or not hasattr(src, "dtype"): # If the source is not an array, convert it to NumPy src = np.asarray(src) if not hasattr(src, "__getitem__"): diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index 08d8d2127..d29ae71a3 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -2102,3 +2102,118 @@ def double(x): t = make_table(["a", "bb"]) t.add_computed_column("dbl", double, inputs=["x"]) np.testing.assert_array_equal(t["dbl"][:], [0, 2]) + + +# --------------------------------------------------------------------------- +# NumPy StringDType interop: dtype-based dispatch to Utf8Array +# --------------------------------------------------------------------------- + + +def test_utf8_array_satisfies_the_blosc2_array_protocol(): + arr = blosc2.utf8_array(["a", "bb", "ccc"]) + assert isinstance(arr, blosc2.Array) + assert arr.shape == (3,) + assert arr.ndim == 1 + assert arr.size == 3 + assert arr.dtype == STRING_DTYPE + + +def test_utf8_array_np_asarray_keeps_string_dtype(): + """np.asarray() used to iterate the rows and infer a fixed-width Date: Wed, 29 Jul 2026 14:41:42 +0200 Subject: [PATCH 64/86] Record the StringDType dispatch decision in the assessment Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 94b801b2a..f7c5b14c7 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -602,3 +602,61 @@ footnote its reference. Renamed to `ComputingUtf8Strings`. With this, items 1–4 of the priority list are done and only item 5 (drop G2/G3/G5, a decision rather than work) remains. + +--- + +## ¹¹ NumPy `StringDType` convention — what was adopted, and what could not be + +`0ed38238`. The question was whether blosc2 should follow NumPy, which builds variable-length text +through a *dtype* (`np.array(v, dtype=StringDType())`) rather than through a separate constructor +(`blosc2.utf8_array(v)`). Answer: adopt the **dispatch**, not the dtype. + +**Why the dtype itself cannot be adopted.** `StringDType` is not a storage format: + +| | | +|---|---| +| `memoryview(arr)` | `ValueError: cannot include dtype 'StringDType' in a buffer` | +| `itemsize` | 16, whatever the content | +| `np.array(["x"*100], dtype=StringDType()).nbytes` | **16** — the payload is elsewhere | +| `.tobytes()` | a handle, not the text (≤15-byte strings are inlined; longer ones are pointers) | + +blosc2's NDArray compresses *buffers*, so `NDArray(dtype=StringDType())` would persist pointers — +garbage on reopen, in another process, or on another machine. Arrow reached the same conclusion, and +`Utf8Array`'s layout **is** Arrow's `large_string`, which is what makes `to_arrow` zero-copy. (The +`ast.literal_eval` failure in `NDArray.dtype` is a symptom, ~5 lines to fix, and fixing it buys +nothing.) + +**Why the schema layer was left alone.** `blosc2.field()` accepts a spec and never a raw dtype, for +*every* column type — `field(np.dtype("int32"))` is a `TypeError` too. Specs carry nullability, the +null sentinel, `ge`/`le`, storage config, `batch_rows`. Making utf8 the one dtype-addressable type +would have *broken* schema uniformity, not restored it. (Also: the runtime floor is `numpy>=1.26`, +where `StringDType` does not exist; `Utf8Spec.dtype = None` is deliberate.) + +**What shipped.** Constructors dispatch on the target dtype, matching NumPy's fill values exactly: + +```python +blosc2.asarray(np.array(["a", "bb"], dtype=StringDType())) # -> Utf8Array +blosc2.zeros(3, dtype=StringDType()) # -> Utf8Array, ['', '', ''] +blosc2.ones(3, dtype=StringDType()) # -> Utf8Array, ['1', '1', '1'] +blosc2.asarray(utf8_source, dtype=" NDArray, fixed width +``` + +Two container gaps closed along the way, both worth more than the dispatch: + +- **`Utf8Array` failed the `blosc2.Array` protocol**, and `.shape` was the *only* member it lacked — + for a container `CTable` uses throughout. It now has `.shape`/`.ndim`/`.size`. +- **`np.asarray(utf8_arr)` silently widened** to a fixed-width ` Date: Wed, 29 Jul 2026 14:49:57 +0200 Subject: [PATCH 65/86] Rename Utf8Array to UTF8Array UTF-8 is an acronym, so it reads better fully capitalised. Renamed with it, for consistency inside the same module: Utf8Factorizer -> UTF8Factorizer, Utf8LazyExpr -> UTF8LazyExpr, and the Utf8Row test fixture. No alias and no deprecation: none of these names has ever shipped. The _utf8_array module does not exist in v4.9.1 and Utf8Array was never in blosc2.__all__ before this branch. Utf8Spec is deliberately left alone -- unlike the rest, it *did* ship in v4.9.1 (via blosc2.schema), so renaming it would break an existing import for a purely cosmetic gain. Lowercase spellings are untouched throughout: the module (_utf8_array), the schema spec (blosc2.utf8()), and the functions (utf8_array, from_utf8, to_utf8) follow PEP 8's lowercase convention, which the acronym argument does not reach. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 22 +++--- doc/reference/classes.rst | 2 +- doc/reference/ctable.rst | 14 ++-- plans/enhancing-ctable-phase3.md | 26 +++---- plans/string-flavours-assessment.md | 24 +++--- plans/utf8-reads-filter-optim.md | 26 +++---- plans/utf8-write-ingest-optim.md | 14 ++-- src/blosc2/__init__.py | 4 +- src/blosc2/_utf8_array.py | 90 +++++++++++------------ src/blosc2/ctable.py | 10 +-- src/blosc2/ctable_storage.py | 14 ++-- src/blosc2/groupby.py | 6 +- src/blosc2/lazyexpr.py | 16 ++-- src/blosc2/ndarray.py | 14 ++-- src/blosc2/proxy.py | 4 +- src/blosc2/scalar_array.py | 2 +- tests/ctable/test_ctable_indexing.py | 8 +- tests/ctable/test_utf8.py | 106 +++++++++++++-------------- 18 files changed, 201 insertions(+), 201 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index c0585f61b..e304f38af 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -40,9 +40,9 @@ XXX version-specific blurb XXX values. Mixed expressions get whatever they can -- in `startswith(name, 'x') | (name == 'zz')` the comparison takes the fast path and `startswith` still decodes. -- **New `blosc2.utf8_array(seq, spec=None)`** builds a `Utf8Array` from an - iterable of strings; `Utf8Array` is exported too. Previously the only - construction path was `Utf8Array(spec)` + `.extend()` + `.flush()`, which +- **New `blosc2.utf8_array(seq, spec=None)`** builds a `UTF8Array` from an + iterable of strings; `UTF8Array` is exported too. Previously the only + construction path was `UTF8Array(spec)` + `.extend()` + `.flush()`, which was not exported at all. - **`df.apply(f, axis=1, engine=blosc2.jit)` now runs `row["colname"]` kernels that contain an `if`.** Neither dispatch route could before: @@ -101,7 +101,7 @@ XXX version-specific blurb XXX evaluated on fixed-width arrays, and the result previously had to be written through the private `t._cols[name].set_all(...)`. A declared default is still honoured for rows appended later, so the two can be combined. -- **`blosc2.from_utf8()` / `blosc2.to_utf8()` and `Utf8Array.astype()`** make +- **`blosc2.from_utf8()` / `blosc2.to_utf8()` and `UTF8Array.astype()`** make the conversion between variable-length and fixed-width text an explicit, documented pair. utf8 columns store and filter text compactly, but string-*returning* expressions need miniexpr's compile-time output width, so @@ -115,21 +115,21 @@ XXX version-specific blurb XXX `blosc2.asarray(np.array([...], dtype=StringDType()))` used to raise `TypeError: data type 'StringDType()' not understood`, and `blosc2.zeros(n, dtype=StringDType())` a `malformed node` `ValueError`; both - now return a `Utf8Array`, as do `empty`, `ones` and `full`, with the same + now return a `UTF8Array`, as do `empty`, `ones` and `full`, with the same fill values NumPy uses (`''`, `''`, `'1'`, `str(fill_value)`). The dispatch is on the *target* dtype, so `asarray(utf8_source, dtype=" Utf8Array - blosc2.zeros(3, dtype=StringDType()) # -> Utf8Array - blosc2.full(3, "x", dtype=StringDType()) # -> Utf8Array + blosc2.asarray(np.array(["a", "bb"], dtype=StringDType())) # -> UTF8Array + blosc2.zeros(3, dtype=StringDType()) # -> UTF8Array + blosc2.full(3, "x", dtype=StringDType()) # -> UTF8Array The fill values match NumPy's own (``''`` for ``zeros``/``empty``, ``'1'`` for ``ones``, ``str(fill_value)`` for ``full``), and the result satisfies the @@ -1116,7 +1116,7 @@ What blosc2 does **not** do is store ``StringDType`` in an :class:`~blosc2.NDArray`, and it cannot: that dtype keeps each row's payload outside the array buffer — a 100-character string still reports ``nbytes == 16`` — and supports no buffer protocol, so compressing the buffer -would persist pointers rather than text. A :class:`Utf8Array` holds the same +would persist pointers rather than text. A :class:`UTF8Array` holds the same text as int64 offsets plus a UTF-8 blob, which is the layout Arrow uses for ``large_string`` and what makes :meth:`CTable.to_arrow` zero-copy. @@ -1151,7 +1151,7 @@ computed, and written back:: :func:`from_utf8` sizes the result to the longest value, counted in codepoints, so nothing truncates and non-ASCII text does not over-allocate. Pass an explicit ``dtype`` to choose the width yourself, which truncates -longer values exactly as NumPy's ``astype`` does. :meth:`Utf8Array.astype` +longer values exactly as NumPy's ``astype`` does. :meth:`UTF8Array.astype` is the same conversion as a method. To overwrite an existing column rather than add one, use @@ -1175,7 +1175,7 @@ naming the column and printing the recipe above: boolean is refused just the same. Only :func:`blosc2.lazyexpr` accepts a utf8 operand directly: it routes to the -span driver and returns a :class:`Utf8Array`, evaluating span by span. +span driver and returns a :class:`UTF8Array`, evaluating span by span. Array, encoded, and compound specs ---------------------------------- diff --git a/plans/enhancing-ctable-phase3.md b/plans/enhancing-ctable-phase3.md index 81236fa6a..de73c5247 100644 --- a/plans/enhancing-ctable-phase3.md +++ b/plans/enhancing-ctable-phase3.md @@ -148,12 +148,12 @@ append/extend, setitem, persistence, repr. - What landed: `Utf8Spec`/`blosc2.utf8()` in `schema.py` (kind `"utf8"`, registered in `schema_compiler._KIND_TO_SPEC`); new `src/blosc2/utf8_array.py` - with the `Utf8Array` adapter; storage dispatch in all four `TableStorage` + with the `UTF8Array` adapter; storage dispatch in all four `TableStorage` backends; sentinel-null wiring; guards for the not-yet-supported operations; 37 tests in `tests/ctable/test_utf8.py`. - **Key deviation from the plan text**: rather than a new column category with its own ~50 dispatch sites (the `DictionaryColumn` route), `Utf8Spec` - joins the `_is_varlen_scalar_column` predicate and `Utf8Array` implements + joins the `_is_varlen_scalar_column` predicate and `UTF8Array` implements the `_ScalarVarLenArray` row interface (`append`/`extend`/`flush`/getitem/ setitem). That made create/open/save/load/copy/take/cframe/TreeStore paths work unmodified; utf8-specific branches exist only where semantics differ: @@ -325,7 +325,7 @@ proves hard, the honest fallback is `np.unique` on the StringDType chunk object/list keys are still rejected. 2. `_read_key_chunk` gained a utf8 branch that pads a chunk read past the column's logical length with `""`, mirroring the P3.a `iter_chunks` fix - — `Utf8Array` is sized to the logical row count, not the physical + — `UTF8Array` is sized to the logical row count, not the physical `valid_rows` capacity, and a chunk boundary can run past it; those rows are provably never live (a row can't be marked valid without every column, including this one, having been written), so the pad value is @@ -368,7 +368,7 @@ proves hard, the honest fallback is `np.unique` on the StringDType chunk Target was ≤3x; actual is ~17x (single key) / ~50x (two keys) — nowhere close. **Root cause, isolated with `cProfile`** (not guessed): 62% of - single-key wall time is `Utf8Array._read_persisted_span`, specifically its + single-key wall time is `UTF8Array._read_persisted_span`, specifically its per-row Python loop (`for i in range(n): out[i] = blob[...].decode("utf-8")`) — 1,998,848 individual `bytes.decode()` calls at 2e6 rows in the profile run. This is a P3.a artifact, not something specific to groupby: every bulk @@ -404,7 +404,7 @@ after the post-review fixes below). Benchmark gate now PASSES.** - What landed — essentially the algorithm sketched above, plus two pipeline fixes the profiling surfaced along the way: - 1. `Utf8Array.factorize_span(a, b)` / incremental `Utf8Factorizer` + 1. `UTF8Array.factorize_span(a, b)` / incremental `UTF8Factorizer` (`utf8_array.py`): rows are grouped by raw byte length (vectorized `bincount`), each length group is gathered column-wise into a `(k, L)` byte matrix (column-wise gather with one reused index vector — ~2x @@ -496,7 +496,7 @@ Reading of the numbers, recorded so the positioning is evidence-backed: - The utf8 read/filter gap is the documented `_read_persisted_span` per-row decode loop: ~1.8 s of the 1e7-row filter is decoding, not comparing. **Natural follow-up (not started):** route comparisons - through the `Utf8Factorizer` the way groupby keys go — compare the D + through the `UTF8Factorizer` the way groupby keys go — compare the D distinct values against the operand, then map codes → boolean mask — which should make low-cardinality utf8 filters competitive with fixed-width. Same idea would speed sort-key materialization. @@ -521,7 +521,7 @@ Reading of the numbers, recorded so the positioning is evidence-backed: (introduced defensively in P3.a). It turned out to be unnecessary rather than merely lifted: `_col_dtype()` for a utf8 column already returns `numpy.dtypes.StringDType()` (not `None`, because - `Utf8Array.dtype` reports it — a P3.a design choice), so the existing + `UTF8Array.dtype` reports it — a P3.a design choice), so the existing `dtype is None` branch that rejects list/vlstring/vlbytes columns already skips utf8 columns for free, and `np.issubdtype(StringDType(), np.complexfloating)` returns `False` @@ -536,7 +536,7 @@ Reading of the numbers, recorded so the positioning is evidence-backed: null-indicator-key logic (`raw == nv` for a non-float sentinel) was already dtype-generic. 3. `_sort_by_inplace` and `_sorted_copy_from_positions` gained a utf8 - branch that rebuilds the column via `Utf8Array.extend()` instead of + branch that rebuilds the column via `UTF8Array.extend()` instead of bulk slice-assignment (`arr[:n] = arr[sorted_pos]`), mirroring the existing list-column branch's `ListArray.extend()` pattern. - **Found and worked around a pre-existing, unrelated bug while verifying @@ -551,7 +551,7 @@ Reading of the numbers, recorded so the positioning is evidence-backed: change, confirmed on the pre-P3 codebase. **Not fixed here** — out of scope for a utf8-only phase, and the acceptance criterion is "`vlstring`/`string` behavior byte-for-byte unchanged," not "fixed." Only - `Utf8Array` got the same treatment `ListArray` already has, since utf8 + `UTF8Array` got the same treatment `ListArray` already has, since utf8 sortability is what this item asks for; every utf8 column in a sorted table — key or bystander — must survive the rewrite, not only the one named in `sort_by(...)`. (Also found and left alone: @@ -582,12 +582,12 @@ branch):** - **Correctness (data corruption, found by review, missed by the suite):** `sort_by(inplace=True)` and `compact()` on a *file-backed* table rebuilt - utf8 columns as fresh in-memory `Utf8Array`s and only rebound + utf8 columns as fresh in-memory `UTF8Array`s and only rebound `self._cols[name]` — the store never saw the rewritten rows, so after close/reopen the utf8 column was corrupted/misaligned with its on-disk-sorted siblings (reproduced: reopen raised `IndexError` on read). All utf8 sort/compact tests were in-memory only, which is why the suite - was green. Fix: new `Utf8Array.set_all(values)` bulk-rewrites through the + was green. Fix: new `UTF8Array.set_all(values)` bulk-rewrites through the *existing* backing offsets/data NDArrays (persistence preserved), used by both call sites; `compact()` also now gathers via the clustered fancy-index read instead of one scalar `__getitem__` (two chunk reads) per @@ -601,7 +601,7 @@ branch):** (dunders pass the numpy ufunc directly). ~2x measured on a 1M-row nullable filter (419 → 203 ms). - **Arrow export:** dense root tables now export utf8 batches straight from - the offsets/bytes buffers via `Utf8Array.arrow_slice()` + the offsets/bytes buffers via `UTF8Array.arrow_slice()` (`pa.LargeStringArray.from_buffers`, sentinel-null mask matched on raw bytes) — no per-row decode, no `.tolist()`, no re-encode. Views/deleted tables use the materializing fallback, which now reuses @@ -610,7 +610,7 @@ branch):** rest is the pre-existing 2048-row `iter_arrow_batches` batching, which re-decompresses each storage chunk many times — pre-existing, not utf8-specific). Tests: view/deleted-rows export and pending-rows export. -- **`Utf8Array.__setitem__`:** the O(n−i) tail move now shifts raw bytes and +- **`UTF8Array.__setitem__`:** the O(n−i) tail move now shifts raw bytes and adds a scalar delta to the tail offsets instead of decoding and re-encoding every following row (21 ms to overwrite row 100 of 1M). Test: grow/shrink/equal/empty replacements persisted across reopen. diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index f7c5b14c7..85e87c65d 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -33,7 +33,7 @@ because two of the conclusions below originally rested on it. | `t.apply(dsl_kernel)` / `lazyudf` | ✓ | ✓ | ✗ NotImpl, routes ¹ ¹⁰ | ✗ | ✗ RuntimeError | | nested (dotted) leaf in expr | ✓ | ✓ | ✗ NotImpl | ✗ | ✗ | | **Bare container (no CTable)** | | | | | | -| `lazyexpr(expr, {a: col})` | ✓ NDArray | ✓ | ✓ span driver, returns `Utf8Array` ² | ⚠ padded ⁶ | ⚠ numpy | +| `lazyexpr(expr, {a: col})` | ✓ NDArray | ✓ | ✓ span driver, returns `UTF8Array` ² | ⚠ padded ⁶ | ⚠ numpy | | `col == "scalar"` | ✓ LazyExpr | ✓ LazyExpr | ✓ bool mask ³ | ✓ bool mask ³ | ✓ bool mask ³ | | **Interop** | | | | | | | `to_arrow` | `string` | `large_binary` | `large_string` | `dictionary<…>` | `string` / `large_binary` | @@ -49,7 +49,7 @@ to a fixed ` Utf8Array -blosc2.zeros(3, dtype=StringDType()) # -> Utf8Array, ['', '', ''] -blosc2.ones(3, dtype=StringDType()) # -> Utf8Array, ['1', '1', '1'] +blosc2.asarray(np.array(["a", "bb"], dtype=StringDType())) # -> UTF8Array +blosc2.zeros(3, dtype=StringDType()) # -> UTF8Array, ['', '', ''] +blosc2.ones(3, dtype=StringDType()) # -> UTF8Array, ['1', '1', '1'] blosc2.asarray(utf8_source, dtype=" NDArray, fixed width ``` Two container gaps closed along the way, both worth more than the dispatch: -- **`Utf8Array` failed the `blosc2.Array` protocol**, and `.shape` was the *only* member it lacked — +- **`UTF8Array` failed the `blosc2.Array` protocol**, and `.shape` was the *only* member it lacked — for a container `CTable` uses throughout. It now has `.shape`/`.ndim`/`.size`. - **`np.asarray(utf8_arr)` silently widened** to a fixed-width ``, `>=`) implementable on raw bytes without decoding. Python `str` comparison is code-point order, so byte-lex results match Python/StringDType semantics exactly. (The - same property already justifies `Utf8Factorizer`'s rank codes.) + same property already justifies `UTF8Factorizer`'s rank codes.) 4. **Null semantics are frozen.** A null (sentinel) value on either side never satisfies any comparison — SQL `WHERE` semantics, pinned by @@ -135,7 +135,7 @@ predicates into miniexpr only if a real workload later proves it pays. ### U1.a Equality (`==`, `!=`) against a `str` scalar -**Where:** a new method on `Utf8Array` (`src/blosc2/utf8_array.py`), plus +**Where:** a new method on `UTF8Array` (`src/blosc2/utf8_array.py`), plus wiring in `Column._utf8_compare` (`src/blosc2/ctable.py`, grep for `def _utf8_compare`). @@ -178,7 +178,7 @@ Key properties to preserve: (`idx = idx + 1` creates one new array per byte position; that is fine — the point is never materializing a `(k, L)` int64 index matrix). - **Pending rows:** call `self.flush()` at the start of the public entry - point (precedent: `Utf8Factorizer.__init__` and `factorize_span` flush; + point (precedent: `UTF8Factorizer.__init__` and `factorize_span` flush; it is a no-op unless there are buffered rows, and read-only tables cannot have any). @@ -209,7 +209,7 @@ currently duplicated. Behavior must not change (its tests pin it). **Algorithm:** per-byte vectorized lexicographic compare against the probe's bytes, grouped by row byte-length (the same grouping loop -`Utf8Array.factorize_span` uses — bincount on `np.diff(rel)`, then one +`UTF8Array.factorize_span` uses — bincount on `np.diff(rel)`, then one iteration per distinct length; distinct lengths are few in practice and each row is touched once regardless). @@ -371,7 +371,7 @@ extension. (own `add_custom_command`, `Python_add_library`, link/install rules). Measured at ~9 ns/row standalone (2e6-row synthetic column), matching the plan's 20-40 ns/row estimate. -- `Utf8Array._read_persisted_span` (`utf8_array.py`) tries the kernel via +- `UTF8Array._read_persisted_span` (`utf8_array.py`) tries the kernel via a new lazy `_pack_utf8_kernel()` helper (mirrors the `try: from blosc2 import groupby_ext / except ImportError: return None` pattern already used in `groupby.py`) and falls back to the old per-row @@ -385,10 +385,10 @@ extension. until both landed: 1. `Column._values_from_key`'s slice fast-path (`ctable.py`) excluded every `is_varlen_scalar` column, including utf8, from the - identity-position direct-slice shortcut, even though `Utf8Array` + identity-position direct-slice shortcut, even though `UTF8Array` slices itself efficiently. Changed the exclusion to `is_varlen_scalar and not is_utf8`. - 2. The real bottleneck: `Utf8Array._get_many` (used whenever + 2. The real bottleneck: `UTF8Array._get_many` (used whenever `_has_identity_positions()` is false — the common case, since a table's physical capacity is normally chunk-padded past its row count) always sorted the index array and did a fancy-indexed @@ -473,5 +473,5 @@ semantics for the C kernels — nothing built in U1 is throwaway. the root cause here and stop. - Never regress `string()`/`vlstring()` behavior or performance; the guard is `bench_string_kinds.py` plus the full test suite. -- No new public API: everything here is internal (`Utf8Array` methods, +- No new public API: everything here is internal (`UTF8Array` methods, `Column._utf8_compare` internals, an optional compiled helper). diff --git a/plans/utf8-write-ingest-optim.md b/plans/utf8-write-ingest-optim.md index 8dc47fcab..634db0ca0 100644 --- a/plans/utf8-write-ingest-optim.md +++ b/plans/utf8-write-ingest-optim.md @@ -42,7 +42,7 @@ discussions that produced this plan. Everything needed is in this file. ## The problem, quantified -`Utf8Array` ingest (`src/blosc2/utf8_array.py`) is far slower than the +`UTF8Array` ingest (`src/blosc2/utf8_array.py`) is far slower than the alternatives on the same benchmark (`bench_string_kinds.py`, `t.extend({"s": values, "val": float_vals}, validate=False)` on 1e7 rows of the Chicago-taxi `company` column): @@ -53,7 +53,7 @@ of the Chicago-taxi `company` column): | `string(max_length=44)` | 478 ms | 7.6x faster | | `vlstring()` | 1193 ms | 3x faster | -**Root cause (verified against current code):** `Utf8Array.extend()` / +**Root cause (verified against current code):** `UTF8Array.extend()` / `.append()` buffer rows one at a time in a pure-Python loop: ```python @@ -158,10 +158,10 @@ losslessly (see the NUL-bearing tests already in `tests/ctable/test_utf8.py`). ### I1.a — Chunked bulk-check `extend()` -**Where:** `Utf8Array.extend` (`src/blosc2/utf8_array.py`). +**Where:** `UTF8Array.extend` (`src/blosc2/utf8_array.py`). Pull `values` in chunks of `_FLUSH_ROWS` via `itertools.islice` (keeps -support for genuinely lazy iterables — `Utf8Array.copy()` calls +support for genuinely lazy iterables — `UTF8Array.copy()` calls `out.extend(self)`). Per chunk, try a bulk fast path; fall back per-item only for that chunk if needed: @@ -218,7 +218,7 @@ complexity to close the soft-bound gap. ### I1.c — Bulk `_rewrite_from` via `str.join` + `isascii()` fast path -**Where:** `Utf8Array._rewrite_from` (`src/blosc2/utf8_array.py`). +**Where:** `UTF8Array._rewrite_from` (`src/blosc2/utf8_array.py`). ```python def _rewrite_from(self, pos: int, values: list[str]) -> None: @@ -480,7 +480,7 @@ not investigated further; not gated by this plan. and stop. - Never regress `string()`/`vlstring()` ingest performance — guard with the full `bench_string_kinds.py` script, not just utf8's rows. -- No new public API — everything here is internal (`Utf8Array` methods, +- No new public API — everything here is internal (`UTF8Array` methods, one new lazy-import helper, one new compiled function in the existing `utf8_ext` module). @@ -488,7 +488,7 @@ not investigated further; not gated by this plan. ## Critical files -- `src/blosc2/utf8_array.py` — `Utf8Array.extend`, `_rewrite_from`, new +- `src/blosc2/utf8_array.py` — `UTF8Array.extend`, `_rewrite_from`, new `_encode_utf8_kernel()` helper (I1.a, I1.c, I2 caller-side wiring). - `src/blosc2/utf8_ext.pyx` — new `encode_utf8_span` function alongside the existing `pack_utf8_span` (I2 kernel). diff --git a/src/blosc2/__init__.py b/src/blosc2/__init__.py index 3ab0ac7ab..764f8f78b 100644 --- a/src/blosc2/__init__.py +++ b/src/blosc2/__init__.py @@ -567,7 +567,7 @@ def _raise(exc): from .tree_store import TreeStore from .batch_array import Batch, BatchArray from .list_array import ListArray -from ._utf8_array import Utf8Array, from_utf8, to_utf8, utf8_array +from ._utf8_array import UTF8Array, from_utf8, to_utf8, utf8_array from .objectarray import ObjectArray, objectarray_from_cframe from .ref import Ref from .b2objects import open_b2object @@ -884,7 +884,7 @@ def _raise(exc): "Tuner", "URLPath", "ObjectArray", - "Utf8Array", + "UTF8Array", # Version "__version__", # Utils diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index 4c7d60785..3a947487a 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -139,11 +139,11 @@ def utf8_span_eval( ): """Evaluate *expr* in row spans, materializing utf8 operands per span. - *arrays* maps operand name to :class:`Utf8Array`, *sentinels* maps the same + *arrays* maps operand name to :class:`UTF8Array`, *sentinels* maps the same names to each one's null sentinel (or ``None``), and *n_phys* is the length of the result; rows past the utf8 operands' logical length keep the zero value of the result dtype. A bool or numeric result is a NumPy array; a - **string** result is a :class:`Utf8Array`, following the contagion rule -- + **string** result is a :class:`UTF8Array`, following the contagion rule -- a string-returning expression with a utf8 operand stays variable-width rather than widening every row to miniexpr's compile-time bound. It is built by extending span by span, so only one span's `` int: return min(len(v) for v in self._utf8.values()) @@ -242,13 +242,13 @@ def shape(self) -> tuple[int, ...]: def compute(self, item=(), **kwargs): """Evaluate the whole expression. - Returns a :class:`Utf8Array` for a string result and a NumPy array for + Returns a :class:`UTF8Array` for a string result and a NumPy array for a boolean or numeric one. ``strict_miniexpr=True`` asserts that evaluation really did reach miniexpr rather than a NumPy fallback. """ if item not in ((), slice(None), Ellipsis): raise NotImplementedError( - "expressions over a bare Utf8Array evaluate whole-array only; " + "expressions over a bare UTF8Array evaluate whole-array only; " "call compute() and slice the result" ) strict = kwargs.pop("strict_miniexpr", False) @@ -274,7 +274,7 @@ def __str__(self) -> str: return self.expression def __repr__(self) -> str: - return f"Utf8LazyExpr({self.expression!r}, shape={self.shape})" + return f"UTF8LazyExpr({self.expression!r}, shape={self.shape})" # Fallback for comparisons against anything that is not a scalar str. @@ -378,7 +378,7 @@ def _new_backend_arrays(cparams=None, dparams=None, *, offsets_urlpath=None, dat return offsets, data -class Utf8Array: +class UTF8Array: """Row-wise variable-length UTF-8 string array over offsets + bytes NDArrays. Provides the row-oriented interface expected by CTable columns: @@ -410,7 +410,7 @@ def __init__(self, spec, offsets=None, data=None) -> None: from blosc2.schema import Utf8Spec if not isinstance(spec, Utf8Spec): - raise TypeError(f"Utf8Array requires a Utf8Spec, got {type(spec)!r}") + raise TypeError(f"UTF8Array requires a Utf8Spec, got {type(spec)!r}") self._dtype = string_dtype() self._spec = spec if (offsets is None) != (data is None): @@ -534,7 +534,7 @@ def _get_many(self, indices: np.ndarray) -> np.ndarray: indices = np.where(indices < 0, indices + n, indices).astype(np.int64, copy=False) m = len(indices) if m and (indices.min() < 0 or indices.max() >= n): - raise IndexError("Utf8Array index out of range") + raise IndexError("UTF8Array index out of range") if m and indices[-1] - indices[0] == m - 1 and bool((np.diff(indices) == 1).all()): # A contiguous ascending run (e.g. a full-column read routed here # via an index array rather than a step-1 slice) is just a span @@ -633,7 +633,7 @@ def set_all(self, values: Iterable[Any]) -> None: Writes through the existing backing offsets/data NDArrays, so a store-backed column stays persistent (unlike building a fresh - in-memory ``Utf8Array``). Used by ``sort_by(inplace=True)`` and + in-memory ``UTF8Array``). Used by ``sort_by(inplace=True)`` and ``compact()`` to rewrite a column in a new row order. """ coerced = [self._coerce(v) for v in values] @@ -658,7 +658,7 @@ def __getitem__(self, index: int | slice | list | tuple | np.ndarray): if index < 0: index += n if not (0 <= index < n): - raise IndexError("Utf8Array index out of range") + raise IndexError("UTF8Array index out of range") if index >= self._persisted_rows: return self._pending[index - self._persisted_rows] return str(self._read_persisted_span(index, index + 1)[0]) @@ -677,7 +677,7 @@ def __getitem__(self, index: int | slice | list | tuple | np.ndarray): if isinstance(index, (list, tuple, np.ndarray)): return self._get_many(np.asarray(index, dtype=np.int64)) - raise TypeError(f"Utf8Array indices must be int, slice, or array; got {type(index)!r}") + raise TypeError(f"UTF8Array indices must be int, slice, or array; got {type(index)!r}") def __setitem__(self, index: int, value: Any) -> None: """Overwrite the value at *index*. @@ -687,14 +687,14 @@ def __setitem__(self, index: int, value: Any) -> None: an O(n - index) operation. """ if not isinstance(index, (int, np.integer)): - raise TypeError(f"Utf8Array assignment index must be int, got {type(index)!r}") + raise TypeError(f"UTF8Array assignment index must be int, got {type(index)!r}") value = self._coerce(value) n = len(self) index = int(index) if index < 0: index += n if not (0 <= index < n): - raise IndexError("Utf8Array index out of range") + raise IndexError("UTF8Array index out of range") if index >= self._persisted_rows: self._pending[index - self._persisted_rows] = value return @@ -733,7 +733,7 @@ def _compare(self, other: Any, op: str) -> np.ndarray: A scalar ``str`` is answered by the raw-byte scanners, which never decode a row. Anything else (a list, an ndarray, another - :class:`Utf8Array`) is materialized and handed to NumPy. + :class:`UTF8Array`) is materialized and handed to NumPy. """ if isinstance(other, str): n = len(self) @@ -742,7 +742,7 @@ def _compare(self, other: Any, op: str) -> np.ndarray: return ~mask if op == "!=" else mask lt, gt = self.order_masks_span(other, 0, n) return {"<": lt, ">": gt, "<=": ~gt, ">=": ~lt}[op] - right = other[:] if isinstance(other, Utf8Array) else other + right = other[:] if isinstance(other, UTF8Array) else other return _COMPARE_OPS[op](np.asarray(self[:]), right) # Identity hashing is kept: these objects were hashable before __eq__ was @@ -848,9 +848,9 @@ def cratio(self) -> float: return float("inf") return self.nbytes / cb - def factorizer(self) -> Utf8Factorizer: + def factorizer(self) -> UTF8Factorizer: """Return a fresh incremental factorizer over this column's rows.""" - return Utf8Factorizer(self) + return UTF8Factorizer(self) def factorize_span(self, a: int, b: int) -> tuple[np.ndarray, np.ndarray]: """Factorize rows ``[a, b)`` without decoding them. @@ -859,7 +859,7 @@ def factorize_span(self, a: int, b: int) -> tuple[np.ndarray, np.ndarray]: array of the distinct values sorted ascending and ``codes`` (int64, length ``b - a``) maps each row to its value — the same contract as ``np.unique(values, return_inverse=True)``, but computed from the raw - offsets/bytes buffers via :class:`Utf8Factorizer`: only the distinct + offsets/bytes buffers via :class:`UTF8Factorizer`: only the distinct values are ever decoded to ``str``. Pending rows are flushed first. """ fact = self.factorizer() @@ -1040,7 +1040,7 @@ def astype(self, dtype=None, *, span_rows: int = UTF8_EXPR_SPAN) -> np.ndarray: else: dtype = np.dtype(dtype) if dtype.kind != "U": - raise ValueError(f"astype() on a Utf8Array needs a U dtype, got {dtype!r}.") + raise ValueError(f"astype() on a UTF8Array needs a U dtype, got {dtype!r}.") if dtype.itemsize == 0: dtype = np.dtype(f" np.ndarray: out[start:stop] = self._read_span(start, stop) return out - def copy(self, spec=None, **kwargs: Any) -> Utf8Array: + def copy(self, spec=None, **kwargs: Any) -> UTF8Array: """Return an in-memory copy.""" if spec is None: spec = self._spec - out = Utf8Array(spec) + out = UTF8Array(spec) out.extend(self) out.flush() return out -def utf8_array(seq, spec=None, **kwargs) -> Utf8Array: - """Build a :class:`Utf8Array` from an iterable of strings. +def utf8_array(seq, spec=None, **kwargs) -> UTF8Array: + """Build a :class:`UTF8Array` from an iterable of strings. Parameters ---------- @@ -1071,11 +1071,11 @@ def utf8_array(seq, spec=None, **kwargs) -> Utf8Array: The :class:`~blosc2.schema.Utf8Spec` describing the array. Defaults to ``blosc2.utf8()`` (non-nullable). kwargs: - Forwarded to :class:`Utf8Array` (``offsets``, ``data``). + Forwarded to :class:`UTF8Array` (``offsets``, ``data``). Returns ------- - Utf8Array + UTF8Array Examples -------- @@ -1086,7 +1086,7 @@ def utf8_array(seq, spec=None, **kwargs) -> Utf8Array: """ import blosc2 - arr = Utf8Array(spec if spec is not None else blosc2.utf8(), **kwargs) + arr = UTF8Array(spec if spec is not None else blosc2.utf8(), **kwargs) arr.extend(seq) arr.flush() return arr @@ -1102,7 +1102,7 @@ def from_utf8(arr, dtype=None) -> np.ndarray: Parameters ---------- arr: - A :class:`Utf8Array`, a utf8 :class:`~blosc2.CTable` column, a NumPy + A :class:`UTF8Array`, a utf8 :class:`~blosc2.CTable` column, a NumPy ``StringDType`` array, or any iterable of ``str``. dtype: Target dtype. ``None`` (or an unsized ``" np.ndarray: 'café' """ raw = getattr(arr, "raw", arr) # a CTable Column exposes its container here - if isinstance(raw, Utf8Array): + if isinstance(raw, UTF8Array): return raw.astype(dtype) values = np.asarray(raw if isinstance(raw, np.ndarray) else list(raw)) if dtype is None or (isinstance(dtype, str) and dtype in ("U", "U", "=U")): @@ -1135,8 +1135,8 @@ def from_utf8(arr, dtype=None) -> np.ndarray: return values.astype(dtype) -def to_utf8(values, spec=None) -> Utf8Array: - """Build a :class:`Utf8Array` from fixed-width or otherwise decoded strings. +def to_utf8(values, spec=None) -> UTF8Array: + """Build a :class:`UTF8Array` from fixed-width or otherwise decoded strings. The inbound half of the pair described in :func:`from_utf8`, and the way a computed string result becomes storable again:: @@ -1156,10 +1156,10 @@ def to_utf8(values, spec=None) -> Utf8Array: Returns ------- - Utf8Array + UTF8Array """ if isinstance(values, np.ndarray): - # tolist() yields plain str, which is Utf8Array.extend's fast path; + # tolist() yields plain str, which is UTF8Array.extend's fast path; # iterating the array yields np.str_, which is not. values = values.tolist() return utf8_array(values, spec) @@ -1176,14 +1176,14 @@ def is_string_dtype(dtype) -> bool: return isinstance(dtype, type) and getattr(dtype, "kind", None) == "T" -def asarray_utf8(array, copy=None, **kwargs) -> Utf8Array: +def asarray_utf8(array, copy=None, **kwargs) -> UTF8Array: """Back :func:`blosc2.asarray` when the *target* dtype is ``StringDType``. A ``StringDType`` array keeps its payload outside its own buffer (a 100 character string still reports ``nbytes == 16``) and offers no buffer protocol at all, so an :class:`~blosc2.NDArray` -- which compresses that buffer -- cannot hold one: it would persist pointers. A - :class:`Utf8Array` holds the same text as offsets + UTF-8 bytes, the + :class:`UTF8Array` holds the same text as offsets + UTF-8 bytes, the layout Arrow uses for ``large_string``, so that is what this returns. """ if kwargs: @@ -1197,13 +1197,13 @@ def asarray_utf8(array, copy=None, **kwargs) -> Utf8Array: f"Variable-length text is 1-D only, got a {ndim}-D array. Reshape it, or ask " "for a fixed-width ' None: + def __init__(self, arr: UTF8Array) -> None: arr.flush() self._arr = arr self._values: list[str] = [] diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index f8708f797..98640c5af 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -1255,7 +1255,7 @@ def _values_from_key(self, key, *, check_stale: bool = True): # noqa: C901 # letting NDArray's strided-gather fast path handle coarse steps. # Plain stored columns only; everything else falls through to the # position-gather path below. utf8 is a varlen-scalar kind but - # Utf8Array slices itself efficiently (offsets+bytes span read), + # UTF8Array slices itself efficiently (offsets+bytes span read), # so it takes the fast path too instead of the index-gather one. if ( not ( @@ -2054,7 +2054,7 @@ def _utf8_chunked_bool(self, fn, *, chunk_size: int = 65536) -> np.ndarray: """Apply ``fn(chunk, start, stop)`` over this utf8 column's logical rows. *fn* returns a boolean array for each ``StringDType`` chunk read from - the underlying :class:`~blosc2._utf8_array.Utf8Array`. Returns a + the underlying :class:`~blosc2._utf8_array.UTF8Array`. Returns a physical-length (``_valid_rows``-length) boolean NumPy array; rows beyond the column's logical length are left ``False``. """ @@ -2071,7 +2071,7 @@ def _utf8_chunked_bytes(self, fn, *, chunk_size: int = 65536) -> np.ndarray: """Apply ``fn(arr, start, stop)`` over this utf8 column's logical rows. Like :meth:`_utf8_chunked_bool`, but *fn* operates directly on the - underlying :class:`~blosc2._utf8_array.Utf8Array` (raw offsets/bytes) + underlying :class:`~blosc2._utf8_array.UTF8Array` (raw offsets/bytes) instead of a materialized ``StringDType`` chunk, so no per-row decode happens. Returns a physical-length boolean NumPy array; rows beyond the column's logical length are left ``False``. @@ -2133,8 +2133,8 @@ def _utf8_scalar_mask(self, numpy_op, value: str) -> np.ndarray: """Raw physical-length boolean mask for ``column value``. Compares raw UTF-8 bytes with no decode to ``StringDType``, via - :meth:`~blosc2._utf8_array.Utf8Array.equal_mask_span` / - :meth:`~blosc2._utf8_array.Utf8Array.order_masks_span`. A null never + :meth:`~blosc2._utf8_array.UTF8Array.equal_mask_span` / + :meth:`~blosc2._utf8_array.UTF8Array.order_masks_span`. A null never satisfies any comparison. Not intersected with the live-row mask -- see :meth:`_utf8_compare_scalar` for that. """ diff --git a/src/blosc2/ctable_storage.py b/src/blosc2/ctable_storage.py index 4003ec370..0fc1c9eec 100644 --- a/src/blosc2/ctable_storage.py +++ b/src/blosc2/ctable_storage.py @@ -28,7 +28,7 @@ import numpy as np import blosc2 -from blosc2._utf8_array import Utf8Array, _new_backend_arrays +from blosc2._utf8_array import UTF8Array, _new_backend_arrays from blosc2.batch_array import BatchArray from blosc2.dictionary_column import DictionaryColumn from blosc2.list_array import ListArray @@ -253,7 +253,7 @@ def open_list_column(self, name): def create_varlen_scalar_column(self, name, *, spec, cparams=None, dparams=None): if isinstance(spec, Utf8Spec): offsets, data = _new_backend_arrays(cparams, dparams) - return Utf8Array(spec, offsets, data) + return UTF8Array(spec, offsets, data) return _ScalarVarLenArray(spec) def open_varlen_scalar_column(self, name, spec): @@ -487,7 +487,7 @@ def open_varlen_scalar_column(self, name: str, spec) -> _ScalarVarLenArray: if isinstance(spec, Utf8Spec): offsets = self._estore[self._col_key(name)] data = self._estore[self._col_key(name) + _UTF8_DATA_SUFFIX] - return Utf8Array(spec, offsets, data) + return UTF8Array(spec, offsets, data) backend = self._estore[self._col_key(name)] return _ScalarVarLenArray(spec, backend) @@ -728,7 +728,7 @@ def create_varlen_scalar_column(self, name, *, spec, cparams=None, dparams=None) data_key = key + _UTF8_DATA_SUFFIX store[key] = offsets store[data_key] = data - return Utf8Array(spec, store[key], store[data_key]) + return UTF8Array(spec, store[key], store[data_key]) urlpath = self._list_col_path(name) backend = _make_persistent_backend(spec, urlpath, "w", cparams=cparams, dparams=dparams) return _ScalarVarLenArray(spec, backend) @@ -738,7 +738,7 @@ def open_varlen_scalar_column(self, name: str, spec) -> _ScalarVarLenArray: store = self._open_store() offsets = store[self._col_key(name)] data = store[self._col_key(name) + _UTF8_DATA_SUFFIX] - return Utf8Array(spec, offsets, data) + return UTF8Array(spec, offsets, data) store = self._open_store() path = self._list_col_path(name) if store.is_zip_store and self._mode == "r": @@ -1316,7 +1316,7 @@ def create_varlen_scalar_column( rel_path = os.path.relpath(dest_path, self._working_dir()).replace(os.sep, "/") self._store.map_tree[self._table_key(logical)] = rel_path self._store._modified = True - return Utf8Array(spec, offsets, data) + return UTF8Array(spec, offsets, data) urlpath = self._list_col_path(name) os.makedirs(os.path.dirname(urlpath), exist_ok=True) return _make_persistent_backend(spec, urlpath, "w", cparams=cparams, dparams=dparams) @@ -1326,7 +1326,7 @@ def open_varlen_scalar_column(self, name: str, spec) -> _ScalarVarLenArray: logical_key = self._col_logical_key(name) offsets = self._open_leaf(logical_key) data = self._open_leaf(logical_key + _UTF8_DATA_SUFFIX) - return Utf8Array(spec, offsets, data) + return UTF8Array(spec, offsets, data) if self._store.is_zip_store and self._mode == "r": rel = self._table_key(self._col_logical_key(name)).lstrip("/") + ".b2b" if rel not in self._store.offsets: diff --git a/src/blosc2/groupby.py b/src/blosc2/groupby.py index 6aaf4747d..4eb1646dd 100644 --- a/src/blosc2/groupby.py +++ b/src/blosc2/groupby.py @@ -61,7 +61,7 @@ class _Utf8KeyChunk: ascending), so null detection, live-row masking, and per-chunk ``np.unique`` all run on int64 codes; only the (few) distinct strings are ever decoded. Produced by :meth:`CTableGroupBy._read_key_chunk` via - ``Utf8Array.factorizer``. + ``UTF8Array.factorizer``. """ codes: np.ndarray @@ -151,7 +151,7 @@ def __init__( self.dropna = bool(dropna) self.engine = engine self.chunk_size = chunk_size - # Per-key incremental Utf8Factorizer instances, shared across the + # Per-key incremental UTF8Factorizer instances, shared across the # chunk loop so the string vocabulary is built once (see # _read_key_chunk). self._utf8_factorizers: dict[str, Any] = {} @@ -1567,7 +1567,7 @@ def _read_key_chunk(self, name: str, start: int, stop: int) -> np.ndarray: # is decoded, only the distinct values (codes flow through the # rest of the pipeline). The factorizer is shared across chunks # so values seen before are hash-matched instead of re-sorted. - # Utf8Array is sized to the logical row count, not the physical + # UTF8Array is sized to the logical row count, not the physical # valid_rows capacity, so a chunk boundary can run past its end; # rows beyond it are never live (the row can't have been written # without this column), so the padding code is never read live. diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 9b4fa074b..75baae649 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -4720,11 +4720,11 @@ def _dsl_kernel_string_dtype(func, inputs): def _guard_utf8_udf_inputs(inputs) -> None: """Reject variable-length utf8 operands in a UDF, naming the conversion.""" - from blosc2._utf8_array import Utf8Array, utf8_compute_error + from blosc2._utf8_array import UTF8Array, utf8_compute_error for operand in inputs or (): raw = getattr(operand, "raw", operand) # a CTable Column exposes its container here - if not isinstance(raw, Utf8Array): + if not isinstance(raw, UTF8Array): continue name = getattr(operand, "_col_name", None) source = f"t[{name!r}]" if name else "arr" @@ -4733,7 +4733,7 @@ def _guard_utf8_udf_inputs(inputs) -> None: ( f"Column {name!r} is a variable-length utf8 column and cannot be a UDF operand." if name - else "A variable-length Utf8Array cannot be a UDF operand." + else "A variable-length UTF8Array cannot be a UDF operand." ), source=source, compute="blosc2.lazyudf(kernel, (fixed,)).compute()[:]", @@ -5371,21 +5371,21 @@ def lazyexpr( [16.0625 21.140625 27. ]] """ if operands is not None and isinstance(expression, str): - # A Utf8Array is variable-width, so it cannot be an expression operand. + # A UTF8Array is variable-width, so it cannot be an expression operand. # It only duck-types as one: LazyExpr would wrap it in a SimpleProxy, # which converts it to a fixed-width bool: def _asarray_string_dispatch(array, copy, kwargs): """Route variable-length text out of :func:`asarray`'s NDArray path. - Returns ``(result, array)``. *result* is a :class:`Utf8Array` when the + Returns ``(result, array)``. *result* is a :class:`UTF8Array` when the **target** dtype is NumPy's ``StringDType``, and ``None`` otherwise -- in which case *array* comes back ready for the fixed-width path, so ``asarray(utf8_source, dtype=" None: Writes each backing batch exactly once, where the equivalent loop over :meth:`__setitem__` would rewrite a whole batch per row. Mirrors - ``Utf8Array.set_all`` so callers can treat both the same way. + ``UTF8Array.set_all`` so callers can treat both the same way. """ coerced = [self._coerce(v) for v in values] if len(coerced) != len(self): diff --git a/tests/ctable/test_ctable_indexing.py b/tests/ctable/test_ctable_indexing.py index 3b9d1ee4f..ca556e077 100644 --- a/tests/ctable/test_ctable_indexing.py +++ b/tests/ctable/test_ctable_indexing.py @@ -1386,7 +1386,7 @@ class NanRow: @dataclasses.dataclass -class Utf8Row: +class UTF8Row: c: str = blosc2.field(blosc2.utf8()) @@ -1395,7 +1395,7 @@ class DictRow: c: str = blosc2.field(blosc2.dictionary()) -@pytest.mark.parametrize(("row_cls", "flavour"), [(Utf8Row, "utf8"), (DictRow, "dictionary")]) +@pytest.mark.parametrize(("row_cls", "flavour"), [(UTF8Row, "utf8"), (DictRow, "dictionary")]) @pytest.mark.parametrize("kind", ["summary", "bucket", "partial", "opsi"]) def test_rank_index_rejects_non_full_kind(tmpdir, row_cls, flavour, kind): """These build over the int32 ranks without error and are then never @@ -1407,7 +1407,7 @@ def test_rank_index_rejects_non_full_kind(tmpdir, row_cls, flavour, kind): assert "c" not in t._get_index_catalog() -@pytest.mark.parametrize(("row_cls", "flavour"), [(Utf8Row, "utf8"), (DictRow, "dictionary")]) +@pytest.mark.parametrize(("row_cls", "flavour"), [(UTF8Row, "utf8"), (DictRow, "dictionary")]) def test_rank_index_accepts_full_kind(tmpdir, row_cls, flavour): t = blosc2.CTable(row_cls, urlpath=str(tmpdir / f"{flavour}_full.b2t"), mode="w") values = [f"v{i % 50:03d}" for i in range(2000)] @@ -1418,7 +1418,7 @@ def test_rank_index_accepts_full_kind(tmpdir, row_cls, flavour): assert sorted(t[t["c"] == "v007"]["c"][:]) == [v for v in values if v == "v007"] -@pytest.mark.parametrize(("row_cls", "flavour"), [(Utf8Row, "utf8"), (DictRow, "dictionary")]) +@pytest.mark.parametrize(("row_cls", "flavour"), [(UTF8Row, "utf8"), (DictRow, "dictionary")]) def test_rank_index_default_kind_is_full(tmpdir, row_cls, flavour): """The BUCKET default would hand these flavours an unusable index.""" t = blosc2.CTable(row_cls, urlpath=str(tmpdir / f"{flavour}_def.b2t"), mode="w") diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index d29ae71a3..158cd1616 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -105,14 +105,14 @@ class Plain: # --------------------------------------------------------------------------- -# Utf8Array internal adapter +# UTF8Array internal adapter # --------------------------------------------------------------------------- def test_utf8_array_basic_roundtrip(): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(SAMPLE) assert len(arr) == len(SAMPLE) assert list(arr[:]) == SAMPLE @@ -124,9 +124,9 @@ def test_utf8_array_basic_roundtrip(): def test_utf8_array_reads_across_pending_boundary(): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(SAMPLE[:4]) arr.flush() arr.extend(SAMPLE[4:]) # stays pending @@ -141,9 +141,9 @@ def test_utf8_array_reads_across_pending_boundary(): def test_utf8_array_setitem_shifts_offsets(): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(["aa", "bb", "cc"]) arr.flush() arr[1] = "a longer replacement value" @@ -153,9 +153,9 @@ def test_utf8_array_setitem_shifts_offsets(): def test_utf8_array_rejects_non_str(): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) with pytest.raises(TypeError, match="Expected str"): arr.append(42) with pytest.raises(TypeError, match="not nullable"): @@ -168,9 +168,9 @@ def test_utf8_array_rejects_non_str(): def test_utf8_array_extend_empty_iterable_is_noop(): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend([]) assert len(arr) == 0 arr.extend(iter([])) @@ -184,11 +184,11 @@ def test_utf8_array_extend_many_rows_no_dropped_rows(): `self._pending` to a fresh list rather than mutating it, so an `extend()` spanning several internal flushes must re-read `self._pending` after each one instead of caching a reference.""" - from blosc2._utf8_array import _FLUSH_ROWS, Utf8Array + from blosc2._utf8_array import _FLUSH_ROWS, UTF8Array n = _FLUSH_ROWS * 3 + 7 values = [f"row{i}" for i in range(n)] - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(values) assert len(arr) == n arr.flush() @@ -197,21 +197,21 @@ def test_utf8_array_extend_many_rows_no_dropped_rows(): def test_utf8_array_extend_none_straddles_chunk_boundary(): - from blosc2._utf8_array import _FLUSH_ROWS, Utf8Array + from blosc2._utf8_array import _FLUSH_ROWS, UTF8Array values = [f"v{i}" for i in range(_FLUSH_ROWS + 2)] values[_FLUSH_ROWS - 1] = None # last row of first chunk values[_FLUSH_ROWS + 1] = None # second row of second chunk - arr = Utf8Array(blosc2.utf8(null_value="")) + arr = UTF8Array(blosc2.utf8(null_value="")) arr.extend(values) expected = [v if v is not None else "" for v in values] assert list(arr[:]) == expected def test_utf8_array_extend_append_interleaved_before_flush(): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.append("first") arr.extend(["second", "third"]) arr.append("fourth") @@ -220,11 +220,11 @@ def test_utf8_array_extend_append_interleaved_before_flush(): def test_utf8_array_extend_ascii_nul_byte_preserved(): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array values = ["nul\x00in", "plain", "\x00leading", "trailing\x00"] assert all(v.isascii() for v in values) - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(values) arr.flush() assert list(arr[:]) == values @@ -235,10 +235,10 @@ def test_utf8_array_extend_multi_mb_strings_bounded_flush(): per _FLUSH_ROWS-sized chunk (not per row), so this overshoots _FLUSH_CHARS by at most one chunk before flushing -- confirm read-back is still correct despite the coarser check.""" - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array values = [f"{i:06d}" + "x" * (2 * 1024 * 1024) for i in range(20)] - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(values) arr.flush() assert list(arr[:]) == values @@ -282,9 +282,9 @@ def test_pack_utf8_span_rejects_malformed_rel(): def test_utf8_array_bulk_read_kernel_and_fallback(force_kernel_mode): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(SAMPLE) arr.flush() got = arr[:] @@ -295,12 +295,12 @@ def test_utf8_array_bulk_read_kernel_and_fallback(force_kernel_mode): def test_utf8_array_bulk_read_matches_python_ground_truth(force_kernel_mode): """A wider mix of byte lengths and edge cases than SAMPLE: many distinct ASCII/multi-byte/empty/NUL-bearing values, read back in one bulk span.""" - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array rng = np.random.default_rng(5) pool = ["", "a", "café", "日本語", "x" * 5000, "nul\x00in", "nul\x00INSIDE", "emoji 🎉🚀"] values = [pool[i] for i in rng.integers(0, len(pool), 3000)] - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(values) arr.flush() assert list(arr[:]) == values @@ -341,9 +341,9 @@ def force_write_kernel_mode(request, monkeypatch): def test_utf8_array_extend_kernel_and_fallback(force_write_kernel_mode): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(SAMPLE) arr.flush() assert list(arr[:]) == SAMPLE @@ -352,22 +352,22 @@ def test_utf8_array_extend_kernel_and_fallback(force_write_kernel_mode): def test_utf8_array_extend_matches_python_ground_truth(force_write_kernel_mode): """Same wider mix of byte lengths and edge cases as the read-side ground-truth test, exercised through the write path this time.""" - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array rng = np.random.default_rng(7) pool = ["", "a", "café", "日本語", "x" * 5000, "nul\x00in", "nul\x00INSIDE", "emoji 🎉🚀"] values = [pool[i] for i in rng.integers(0, len(pool), 3000)] - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(values) arr.flush() assert list(arr[:]) == values def test_utf8_array_extend_ascii_nul_byte_kernel_and_fallback(force_write_kernel_mode): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array values = ["nul\x00in", "plain", "\x00leading", "trailing\x00"] - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(values) arr.flush() assert list(arr[:]) == values @@ -376,10 +376,10 @@ def test_utf8_array_extend_ascii_nul_byte_kernel_and_fallback(force_write_kernel def test_utf8_array_extend_multi_mb_string_kernel_and_fallback(force_write_kernel_mode): """A single multi-MB value alongside short ones -- sanity-checks the total-length/offset accumulation in the compiled kernel's two passes.""" - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array values = ["head", "x" * (8 * 1024 * 1024), "tail", "café" * 100_000] - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(values) arr.flush() assert list(arr[:]) == values @@ -395,9 +395,9 @@ def test_utf8_array_extend_lone_surrogate_raises_and_recovers(force_write_kernel UnicodeEncodeError, matching str.encode('utf-8')'s own behavior, and the array must remain usable afterwards -- a regression test for the compiled kernel's temp-buffer cleanup on the error path.""" - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(["first"]) arr.flush() arr.extend(["ok", "bad\udc80value"]) @@ -897,12 +897,12 @@ def test_utf8_factorize_span_matches_np_unique_contract(): numpy's np.unique on StringDType merges strings differing only after an embedded NUL (numpy bug), which the byte-exact factorization does not. """ - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array rng = np.random.default_rng(7) pool = ["", "a", "ab", "café", "日本語", "x" * 3000, "nul\x00in", "nul\x00IN", "Wien", "wien"] values = [pool[i] for i in rng.integers(0, len(pool), 5000)] - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(values) codes, uniques = arr.factorize_span(0, len(values)) assert list(uniques) == sorted(set(values)) @@ -910,9 +910,9 @@ def test_utf8_factorize_span_matches_np_unique_contract(): def test_utf8_factorizer_cross_span_codes_are_global(): - from blosc2._utf8_array import Utf8Array + from blosc2._utf8_array import UTF8Array - arr = Utf8Array(blosc2.utf8()) + arr = UTF8Array(blosc2.utf8()) arr.extend(["b", "a", "b", "c", "a", "d"]) fact = arr.factorizer() c1 = fact.codes_for_span(0, 3) # b, a, b @@ -1331,7 +1331,7 @@ def test_utf8_duckdb_query(): def test_utf8_array_constructor(): arr = blosc2.utf8_array(SAMPLE) - assert isinstance(arr, blosc2.Utf8Array) + assert isinstance(arr, blosc2.UTF8Array) assert len(arr) == len(SAMPLE) assert list(arr[:]) == SAMPLE @@ -1426,12 +1426,12 @@ def test_ctable_utf8_string_result_is_a_utf8_array(): A `` Date: Wed, 29 Jul 2026 14:54:41 +0200 Subject: [PATCH 66/86] Rename Utf8Spec to UTF8Spec, keeping the old name as an alias Completes the acronym capitalisation. Unlike the classes renamed in the previous commit, this one shipped in v4.9.1 (reachable as blosc2.schema.Utf8Spec), so `Utf8Spec = UTF8Spec` stays behind for anyone who imported it. Persisted schemas are unaffected in either direction: a utf8 column records `kind: "utf8"` and is reconstructed through schema_compiler's kind registry, never from the class name. Verified by a save/reopen round trip carrying a nullable utf8 column with a custom sentinel. Co-Authored-By: Claude Opus 5 --- plans/enhancing-ctable-phase3.md | 12 ++++++------ plans/string-flavours-assessment.md | 6 +++--- src/blosc2/_utf8_array.py | 14 +++++++------- src/blosc2/ctable.py | 20 ++++++++++---------- src/blosc2/ctable_indexing.py | 10 +++++----- src/blosc2/ctable_storage.py | 14 +++++++------- src/blosc2/schema.py | 12 +++++++++--- src/blosc2/schema_compiler.py | 6 +++--- tests/ctable/test_dictionary_column.py | 4 ++-- tests/ctable/test_utf8.py | 2 +- 10 files changed, 53 insertions(+), 47 deletions(-) diff --git a/plans/enhancing-ctable-phase3.md b/plans/enhancing-ctable-phase3.md index de73c5247..9cd211a36 100644 --- a/plans/enhancing-ctable-phase3.md +++ b/plans/enhancing-ctable-phase3.md @@ -146,13 +146,13 @@ append/extend, setitem, persistence, repr. **P3.a implementation notes (landed 2026-07-16, commit e5bbd559, branch `enhancing-ctable3`):** -- What landed: `Utf8Spec`/`blosc2.utf8()` in `schema.py` (kind `"utf8"`, +- What landed: `UTF8Spec`/`blosc2.utf8()` in `schema.py` (kind `"utf8"`, registered in `schema_compiler._KIND_TO_SPEC`); new `src/blosc2/utf8_array.py` with the `UTF8Array` adapter; storage dispatch in all four `TableStorage` backends; sentinel-null wiring; guards for the not-yet-supported operations; 37 tests in `tests/ctable/test_utf8.py`. - **Key deviation from the plan text**: rather than a new column category - with its own ~50 dispatch sites (the `DictionaryColumn` route), `Utf8Spec` + with its own ~50 dispatch sites (the `DictionaryColumn` route), `UTF8Spec` joins the `_is_varlen_scalar_column` predicate and `UTF8Array` implements the `_ScalarVarLenArray` row interface (`append`/`extend`/`flush`/getitem/ setitem). That made create/open/save/load/copy/take/cframe/TreeStore paths @@ -200,7 +200,7 @@ on a utf8 column, `from_arrow(pa_table)` ingest. **P3.b implementation notes (landed 2026-07-16, branch `enhancing-ctable3`):** -- What landed: `_pa_type_from_spec` maps `Utf8Spec` → `pa.large_string()` +- What landed: `_pa_type_from_spec` maps `UTF8Spec` → `pa.large_string()` (always large, per the plan); `iter_arrow_batches` builds a null mask from the sentinel and exports proper Arrow nulls; `_arrow_type_to_spec` now maps incoming Arrow `string`/`large_string` (when `string_max_length` is not @@ -342,7 +342,7 @@ proves hard, the honest fallback is `np.unique` on the StringDType chunk fixed-width dispatch and the single-key path already falls through to the correct `np.unique(arr, return_inverse=True)` for free. `_null_mask` already worked unmodified (`values == null_value` on a `StringDType` array is - correct). `_result_spec_for_key` deep-copies the source `Utf8Spec` + correct). `_result_spec_for_key` deep-copies the source `UTF8Spec` unmodified, so a groupby result's key column is itself a utf8 column (`Column.is_utf8` true). `_python_type_for_spec`/`_python_scalar` already produce plain `str` for utf8 (indexing a `StringDType` array yields @@ -614,10 +614,10 @@ branch):** adds a scalar delta to the tail offsets instead of decoding and re-encoding every following row (21 ms to overwrite row 100 of 1M). Test: grow/shrink/equal/empty replacements persisted across reopen. -- **Cleanup:** `Utf8Spec` imported once at `ctable_storage.py` module top +- **Cleanup:** `UTF8Spec` imported once at `ctable_storage.py` module top (was: six local imports); `FileTableStorage.create_varlen_scalar_column` hoists the column key. -- **Review finding rejected on inspection:** removing `Utf8Spec.__init__`'s +- **Review finding rejected on inspection:** removing `UTF8Spec.__init__`'s inline `null_value must be str` check (flagged as duplicating `_validate_null_value_for_spec`) would open a validation hole — `_resolve_nullable_specs` *skips* specs whose `null_value` is already set, diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 85e87c65d..521f183b5 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -482,7 +482,7 @@ This is a property of the **rank-index design**, not of utf8 — `_dictionary_in identically. And the default `kind=IndexKind.BUCKET` meant `create_index("c")` on a dictionary column had *always* built an index nothing could use. -**Fixed.** `kind` now defaults to `None` and resolves to `FULL` for `Utf8Spec`/`DictionarySpec` +**Fixed.** `kind` now defaults to `None` and resolves to `FULL` for `UTF8Spec`/`DictionarySpec` (`BUCKET` unchanged everywhere else); an *explicit* non-FULL kind on those flavours raises `ValueError` naming the reason. Erroring rather than warning because it is not a trade-off — there is no workload where those kinds help — and because relaxing an error later is non-breaking while @@ -630,7 +630,7 @@ nothing.) *every* column type — `field(np.dtype("int32"))` is a `TypeError` too. Specs carry nullability, the null sentinel, `ge`/`le`, storage config, `batch_rows`. Making utf8 the one dtype-addressable type would have *broken* schema uniformity, not restored it. (Also: the runtime floor is `numpy>=1.26`, -where `StringDType` does not exist; `Utf8Spec.dtype = None` is deliberate.) +where `StringDType` does not exist; `UTF8Spec.dtype = None` is deliberate.) **What shipped.** Constructors dispatch on the target dtype, matching NumPy's fill values exactly: @@ -657,6 +657,6 @@ into fixed-width elements; the span driver is the path that avoids it), so it is than incidental, and goes through `astype()`, which sizes the result without decoding a row. A reminder that `hasattr` probes for capability make silent contracts out of missing attributes. -Deliberately not done: making `Utf8Spec.dtype` return `StringDType()`. It would have to stay lazy +Deliberately not done: making `UTF8Spec.dtype` return `StringDType()`. It would have to stay lazy for NumPy 1.26, `dtype is None` is load-bearing at three sites, and `Column.dtype` already reports `StringDType()` — so the win is cosmetic. diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index 3a947487a..7b5691108 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -392,12 +392,12 @@ class UTF8Array: This class is internal; obtain instances via ``storage.create_varlen_scalar_column()`` or - ``storage.open_varlen_scalar_column()`` with a ``Utf8Spec``. + ``storage.open_varlen_scalar_column()`` with a ``UTF8Spec``. Parameters ---------- spec: - The :class:`~blosc2.schema.Utf8Spec` describing this column. + The :class:`~blosc2.schema.UTF8Spec` describing this column. offsets: ``int64`` NDArray of row offsets (length ``n + 1``). Created fresh (in memory) when ``None``. @@ -407,10 +407,10 @@ class UTF8Array: """ def __init__(self, spec, offsets=None, data=None) -> None: - from blosc2.schema import Utf8Spec + from blosc2.schema import UTF8Spec - if not isinstance(spec, Utf8Spec): - raise TypeError(f"UTF8Array requires a Utf8Spec, got {type(spec)!r}") + if not isinstance(spec, UTF8Spec): + raise TypeError(f"UTF8Array requires a UTF8Spec, got {type(spec)!r}") self._dtype = string_dtype() self._spec = spec if (offsets is None) != (data is None): @@ -1068,7 +1068,7 @@ def utf8_array(seq, spec=None, **kwargs) -> UTF8Array: seq: Iterable of ``str`` (or ``None`` for a nullable *spec*). spec: - The :class:`~blosc2.schema.Utf8Spec` describing the array. Defaults + The :class:`~blosc2.schema.UTF8Spec` describing the array. Defaults to ``blosc2.utf8()`` (non-nullable). kwargs: Forwarded to :class:`UTF8Array` (``offsets``, ``data``). @@ -1151,7 +1151,7 @@ def to_utf8(values, spec=None) -> UTF8Array: NumPy ``U``/``StringDType`` array, or any iterable of ``str`` (or ``None`` for a nullable *spec*). spec: - The :class:`~blosc2.schema.Utf8Spec` describing the result. Defaults + The :class:`~blosc2.schema.UTF8Spec` describing the result. Defaults to ``blosc2.utf8()`` (non-nullable). Returns diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 98640c5af..19e848c26 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -55,7 +55,7 @@ ObjectSpec, SchemaSpec, StructSpec, - Utf8Spec, + UTF8Spec, VLBytesSpec, VLStringSpec, complex64, @@ -1118,14 +1118,14 @@ def is_varlen_scalar(self) -> bool: """True if this column holds variable-length scalar strings or bytes.""" col = self._table._schema.columns_by_name.get(self._col_name) return col is not None and isinstance( - col.spec, (VLStringSpec, VLBytesSpec, StructSpec, ObjectSpec, Utf8Spec) + col.spec, (VLStringSpec, VLBytesSpec, StructSpec, ObjectSpec, UTF8Spec) ) @property def is_utf8(self) -> bool: """True if this column stores variable-length UTF-8 strings (offsets + bytes).""" col = self._table._schema.columns_by_name.get(self._col_name) - return col is not None and isinstance(col.spec, Utf8Spec) + return col is not None and isinstance(col.spec, UTF8Spec) @property def is_dictionary(self) -> bool: @@ -4275,11 +4275,11 @@ def _is_list_column(col: CompiledColumn) -> bool: @staticmethod def _is_varlen_scalar_column(col: CompiledColumn) -> bool: - return isinstance(col.spec, (VLStringSpec, VLBytesSpec, StructSpec, ObjectSpec, Utf8Spec)) + return isinstance(col.spec, (VLStringSpec, VLBytesSpec, StructSpec, ObjectSpec, UTF8Spec)) @staticmethod def _is_utf8_column(col: CompiledColumn) -> bool: - return isinstance(col.spec, Utf8Spec) + return isinstance(col.spec, UTF8Spec) @staticmethod def _is_dictionary_column(col: CompiledColumn) -> bool: @@ -4431,7 +4431,7 @@ def _policy_null_value_for_spec(spec: SchemaSpec, policy: NullPolicy): return policy.float_value if isinstance(spec, b2_bool): return policy.bool_value - if isinstance(spec, (string, Utf8Spec)): + if isinstance(spec, (string, UTF8Spec)): return policy.string_value if isinstance(spec, b2_bytes): return policy.bytes_value @@ -4481,7 +4481,7 @@ def _validate_null_value_for_spec(name: str, spec: SchemaSpec, null_value) -> No if null_value != 255: raise ValueError(f"Null sentinel for nullable bool column {name!r} must be 255") return - if isinstance(spec, (string, Utf8Spec)): + if isinstance(spec, (string, UTF8Spec)): if not isinstance(null_value, str): raise TypeError(f"Null sentinel for string column {name!r} must be str") return @@ -6908,7 +6908,7 @@ def _resolve_arrow_columns(self, columns, include_computed: bool = True) -> list def _pa_type_from_spec(pa, spec): if isinstance(spec, DictionarySpec): return pa.dictionary(pa.int32(), pa.string(), ordered=spec.ordered) - if isinstance(spec, Utf8Spec): + if isinstance(spec, UTF8Spec): # Always large_string: 64-bit offsets match the int64 offsets array, # so multi-GB string columns export without int32-offset overflow. return pa.large_string() @@ -9207,7 +9207,7 @@ def _varlen_filler(spec, default): return null_value if isinstance(spec, VLBytesSpec): return b"" - if isinstance(spec, (Utf8Spec, VLStringSpec)): + if isinstance(spec, (UTF8Spec, VLStringSpec)): return "" return None @@ -12569,7 +12569,7 @@ def _dtype_info_label(dtype: np.dtype | None, spec: SchemaSpec | None = None) -> if isinstance(spec, DictionarySpec): ordered_tag = ", ordered" if spec.ordered else "" return f"dictionary[str{ordered_tag}]" - if isinstance(spec, Utf8Spec): + if isinstance(spec, UTF8Spec): return "utf8" if isinstance(spec, VLStringSpec): return "vlstring" diff --git a/src/blosc2/ctable_indexing.py b/src/blosc2/ctable_indexing.py index fe3a0b452..20019df16 100644 --- a/src/blosc2/ctable_indexing.py +++ b/src/blosc2/ctable_indexing.py @@ -26,7 +26,7 @@ NDArraySpec, ObjectSpec, StructSpec, - Utf8Spec, + UTF8Spec, VLBytesSpec, VLStringSpec, ) @@ -769,7 +769,7 @@ def create_index( # noqa: C901 spec = col_info.spec if col_info is not None else None kind = ( blosc2.IndexKind.FULL - if isinstance(spec, (Utf8Spec, DictionarySpec)) + if isinstance(spec, (UTF8Spec, DictionarySpec)) else blosc2.IndexKind.BUCKET ) @@ -860,8 +860,8 @@ def create_index( # noqa: C901 # the ranks without error and are then never consulted, so refuse them # here rather than charge for an index nothing can use. rank_spec = self._schema.columns_by_name[col_name].spec - if explicit_kind and kind_str != "full" and isinstance(rank_spec, (Utf8Spec, DictionarySpec)): - flavour = "utf8" if isinstance(rank_spec, Utf8Spec) else "dictionary" + if explicit_kind and kind_str != "full" and isinstance(rank_spec, (UTF8Spec, DictionarySpec)): + flavour = "utf8" if isinstance(rank_spec, UTF8Spec) else "dictionary" raise ValueError( f"Column {col_name!r} is a {flavour} column, which is indexed by alphabetical rank; " f"only kind='full' consults that index, so kind={kind_str!r} would build but never " @@ -870,7 +870,7 @@ def create_index( # noqa: C901 # utf8 columns: index the alphabetical rank of each row's value. There is # no stored code array to wrap lazily, so the ranks are materialized here # (int32, 4 B/row) and handed to the builder as an ordinary array. - is_utf8 = isinstance(self._schema.columns_by_name[col_name].spec, Utf8Spec) + is_utf8 = isinstance(self._schema.columns_by_name[col_name].spec, UTF8Spec) utf8_rank_meta = None if is_utf8: n_live = self._n_rows if self._n_rows is not None else len(self._valid_rows) diff --git a/src/blosc2/ctable_storage.py b/src/blosc2/ctable_storage.py index 0fc1c9eec..0d01b4d44 100644 --- a/src/blosc2/ctable_storage.py +++ b/src/blosc2/ctable_storage.py @@ -38,7 +38,7 @@ _ScalarVarLenArray, _validate_role_metadata, ) -from blosc2.schema import Utf8Spec +from blosc2.schema import UTF8Spec from blosc2.schunk import process_opened_object if TYPE_CHECKING: @@ -251,7 +251,7 @@ def open_list_column(self, name): raise RuntimeError("In-memory tables have no on-disk representation to open.") def create_varlen_scalar_column(self, name, *, spec, cparams=None, dparams=None): - if isinstance(spec, Utf8Spec): + if isinstance(spec, UTF8Spec): offsets, data = _new_backend_arrays(cparams, dparams) return UTF8Array(spec, offsets, data) return _ScalarVarLenArray(spec) @@ -484,7 +484,7 @@ def open_list_column(self, name: str) -> ListArray: return self._estore[self._col_key(name)] def open_varlen_scalar_column(self, name: str, spec) -> _ScalarVarLenArray: - if isinstance(spec, Utf8Spec): + if isinstance(spec, UTF8Spec): offsets = self._estore[self._col_key(name)] data = self._estore[self._col_key(name) + _UTF8_DATA_SUFFIX] return UTF8Array(spec, offsets, data) @@ -721,7 +721,7 @@ def open_list_column(self, name: str) -> ListArray: return blosc2.open(self._list_col_path(name), mode=self._mode) def create_varlen_scalar_column(self, name, *, spec, cparams=None, dparams=None) -> _ScalarVarLenArray: - if isinstance(spec, Utf8Spec): + if isinstance(spec, UTF8Spec): offsets, data = _new_backend_arrays(cparams, dparams) store = self._open_store() key = self._col_key(name) @@ -734,7 +734,7 @@ def create_varlen_scalar_column(self, name, *, spec, cparams=None, dparams=None) return _ScalarVarLenArray(spec, backend) def open_varlen_scalar_column(self, name: str, spec) -> _ScalarVarLenArray: - if isinstance(spec, Utf8Spec): + if isinstance(spec, UTF8Spec): store = self._open_store() offsets = store[self._col_key(name)] data = store[self._col_key(name) + _UTF8_DATA_SUFFIX] @@ -1301,7 +1301,7 @@ def create_varlen_scalar_column( cparams=None, dparams=None, ) -> _ScalarVarLenArray: - if isinstance(spec, Utf8Spec): + if isinstance(spec, UTF8Spec): logical_key = self._col_logical_key(name) offsets_path = self._dest_path(logical_key, ".b2nd") data_path = self._dest_path(logical_key + _UTF8_DATA_SUFFIX, ".b2nd") @@ -1322,7 +1322,7 @@ def create_varlen_scalar_column( return _make_persistent_backend(spec, urlpath, "w", cparams=cparams, dparams=dparams) def open_varlen_scalar_column(self, name: str, spec) -> _ScalarVarLenArray: - if isinstance(spec, Utf8Spec): + if isinstance(spec, UTF8Spec): logical_key = self._col_logical_key(name) offsets = self._open_leaf(logical_key) data = self._open_leaf(logical_key + _UTF8_DATA_SUFFIX) diff --git a/src/blosc2/schema.py b/src/blosc2/schema.py index 52c19f0b5..738fcc1f2 100644 --- a/src/blosc2/schema.py +++ b/src/blosc2/schema.py @@ -597,7 +597,7 @@ def to_metadata_dict(self) -> dict[str, Any]: return d -class Utf8Spec(SchemaSpec): +class UTF8Spec(SchemaSpec): """Variable-length UTF-8 string column stored Arrow-style as offsets + bytes. Unlike :class:`string`, this spec does not use a fixed-width NumPy dtype: @@ -651,6 +651,12 @@ def display_label(self) -> str: return "utf8" +#: Deprecated alias kept for the name this class shipped under in 4.9.1. +#: Persisted schemas are unaffected either way -- they record ``kind: "utf8"``, +#: never the class name. +Utf8Spec = UTF8Spec + + class ObjectSpec(SchemaSpec): """Schema-less Python object column backed by batched msgpack storage. @@ -844,7 +850,7 @@ def vlstring( ) -def utf8(*, nullable: bool = False, null_value: str | None = None) -> Utf8Spec: +def utf8(*, nullable: bool = False, null_value: str | None = None) -> UTF8Spec: """Build a variable-length UTF-8 string schema descriptor. Use this for high-cardinality or free-text string columns: values are @@ -888,7 +894,7 @@ def utf8(*, nullable: bool = False, null_value: str | None = None) -> Utf8Spec: from blosc2._utf8_array import string_dtype string_dtype() # fail early with a clear error on NumPy < 2.0 - return Utf8Spec(nullable=nullable, null_value=null_value) + return UTF8Spec(nullable=nullable, null_value=null_value) def object( diff --git a/src/blosc2/schema_compiler.py b/src/blosc2/schema_compiler.py index b295dd51e..2475474d7 100644 --- a/src/blosc2/schema_compiler.py +++ b/src/blosc2/schema_compiler.py @@ -28,7 +28,7 @@ ObjectSpec, SchemaSpec, StructSpec, - Utf8Spec, + UTF8Spec, VLBytesSpec, VLStringSpec, complex64, @@ -77,7 +77,7 @@ "bytes": b2_bytes, "vlstring": VLStringSpec, "vlbytes": VLBytesSpec, - "utf8": Utf8Spec, + "utf8": UTF8Spec, "object": ObjectSpec, "timestamp": timestamp, # dictionary @@ -112,7 +112,7 @@ def compute_display_width(spec: SchemaSpec) -> int: """Return a reasonable terminal display width for *spec*'s column.""" if isinstance(spec, DictionarySpec): return 32 - if isinstance(spec, (VLStringSpec, VLBytesSpec, ObjectSpec, Utf8Spec)): + if isinstance(spec, (VLStringSpec, VLBytesSpec, ObjectSpec, UTF8Spec)): return 40 if isinstance(spec, NDArraySpec): return max(20, len(spec.display_label()) + 4) diff --git a/tests/ctable/test_dictionary_column.py b/tests/ctable/test_dictionary_column.py index c9791120c..0770e4b1a 100644 --- a/tests/ctable/test_dictionary_column.py +++ b/tests/ctable/test_dictionary_column.py @@ -494,7 +494,7 @@ def test_cli_preserves_dict_by_default(tmp_path): def test_cli_decode_dictionaries_flag(tmp_path): from blosc2.cli.parquet_to_blosc2 import main - from blosc2.schema import Utf8Spec, VLStringSpec + from blosc2.schema import UTF8Spec, VLStringSpec path = tmp_path / "dict.parquet" out = tmp_path / "dict_decoded.b2d" @@ -509,7 +509,7 @@ def test_cli_decode_dictionaries_flag(tmp_path): from blosc2._utf8_array import have_string_dtype # Decoded strings become utf8 columns on NumPy >= 2.0, vlstring on older NumPy. - expected_spec = Utf8Spec if have_string_dtype() else VLStringSpec + expected_spec = UTF8Spec if have_string_dtype() else VLStringSpec assert isinstance(ct._schema.columns_by_name["vendor"].spec, expected_spec) assert list(ct["vendor"][:]) == ["Uber", "Lyft", "Uber"] ct.close() diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index 158cd1616..addb977f9 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -77,7 +77,7 @@ def test_utf8_spec_metadata_round_trip(): assert d["null_value"] == "" restored = spec_from_metadata_dict(d) - assert type(restored).__name__ == "Utf8Spec" + assert type(restored).__name__ == "UTF8Spec" assert restored.nullable is True assert restored.null_value == "" From 7eca6f6551af9d2b782c04422ba1914d77248aa9 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 15:06:02 +0200 Subject: [PATCH 67/86] Keep the string tests running on NumPy 1.26 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI's NumPy 1.26 job failed 17 tests in test_string_output.py. Every one was a *reference* computation, not the blosc2 call under test: `np.strings` and the `+` ufunc loop for U/S arrays are both NumPy 2.0 additions, so `expected = np.strings.upper(names)` and `expected = names + other` raised while the miniexpr side they were being compared against ran fine. `np.char` is the equivalent that exists in both, and is an exact substitute: same values, same result dtypes, and the same full case mapping (straße -> STRASSE) the case-expansion test is there to pin down. Verified against np.strings on NumPy 2.4.6 before swapping. Fixed alongside, and not yet seen by CI: test_ctable_indexing.py builds a utf8 dataclass at module scope, and blosc2.utf8() raises on NumPy < 2.0 (it calls string_dtype() to fail early), so the whole module would have failed to *collect* rather than merely failing a few tests. The utf8 half of the three rank-index parametrizations now carries a skipif; the dictionary half still runs, since it needs no StringDType. Checked by running the suite with numpy.dtypes.StringDType deleted: no collection errors and no failures. Co-Authored-By: Claude Opus 5 --- tests/ctable/test_ctable_indexing.py | 32 ++++++++++++++++++++----- tests/ndarray/test_string_output.py | 36 ++++++++++++++++++---------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/tests/ctable/test_ctable_indexing.py b/tests/ctable/test_ctable_indexing.py index ca556e077..1d54297c4 100644 --- a/tests/ctable/test_ctable_indexing.py +++ b/tests/ctable/test_ctable_indexing.py @@ -1385,9 +1385,22 @@ class NanRow: # --------------------------------------------------------------------------- -@dataclasses.dataclass -class UTF8Row: - c: str = blosc2.field(blosc2.utf8()) +_needs_string_dtype = pytest.mark.skipif( + not hasattr(np.dtypes, "StringDType"), + reason="utf8 columns require NumPy >= 2.0 (StringDType)", +) + +if hasattr(np.dtypes, "StringDType"): + + @dataclasses.dataclass + class UTF8Row: + c: str = blosc2.field(blosc2.utf8()) + +else: + # blosc2.utf8() raises on NumPy < 2.0, so the class cannot even be defined + # there. Every parametrization using it carries _needs_string_dtype, so + # this placeholder is never dereferenced. + UTF8Row = None @dataclasses.dataclass @@ -1395,7 +1408,14 @@ class DictRow: c: str = blosc2.field(blosc2.dictionary()) -@pytest.mark.parametrize(("row_cls", "flavour"), [(UTF8Row, "utf8"), (DictRow, "dictionary")]) +#: The two flavours whose indexes are rank-based, utf8 skipped on NumPy 1.x. +RANK_FLAVOURS = [ + pytest.param(UTF8Row, "utf8", marks=_needs_string_dtype), + pytest.param(DictRow, "dictionary"), +] + + +@pytest.mark.parametrize(("row_cls", "flavour"), RANK_FLAVOURS) @pytest.mark.parametrize("kind", ["summary", "bucket", "partial", "opsi"]) def test_rank_index_rejects_non_full_kind(tmpdir, row_cls, flavour, kind): """These build over the int32 ranks without error and are then never @@ -1407,7 +1427,7 @@ def test_rank_index_rejects_non_full_kind(tmpdir, row_cls, flavour, kind): assert "c" not in t._get_index_catalog() -@pytest.mark.parametrize(("row_cls", "flavour"), [(UTF8Row, "utf8"), (DictRow, "dictionary")]) +@pytest.mark.parametrize(("row_cls", "flavour"), RANK_FLAVOURS) def test_rank_index_accepts_full_kind(tmpdir, row_cls, flavour): t = blosc2.CTable(row_cls, urlpath=str(tmpdir / f"{flavour}_full.b2t"), mode="w") values = [f"v{i % 50:03d}" for i in range(2000)] @@ -1418,7 +1438,7 @@ def test_rank_index_accepts_full_kind(tmpdir, row_cls, flavour): assert sorted(t[t["c"] == "v007"]["c"][:]) == [v for v in values if v == "v007"] -@pytest.mark.parametrize(("row_cls", "flavour"), [(UTF8Row, "utf8"), (DictRow, "dictionary")]) +@pytest.mark.parametrize(("row_cls", "flavour"), RANK_FLAVOURS) def test_rank_index_default_kind_is_full(tmpdir, row_cls, flavour): """The BUCKET default would hand these flavours an unusable index.""" t = blosc2.CTable(row_cls, urlpath=str(tmpdir / f"{flavour}_def.b2t"), mode="w") diff --git a/tests/ndarray/test_string_output.py b/tests/ndarray/test_string_output.py index e8f3c455c..46acdee49 100644 --- a/tests/ndarray/test_string_output.py +++ b/tests/ndarray/test_string_output.py @@ -18,6 +18,18 @@ import blosc2 from blosc2 import blosc2_ext +# NumPy 2.0 added the `np.strings` namespace and the `+` ufunc loop for U/S +# arrays; blosc2 still supports NumPy 1.26, where `np.char` is the equivalent +# and `arr + arr` raises "ufunc 'add' did not contain a loop". Only the +# *reference* values below need this -- what is under test runs on both. +np_strings = getattr(np, "strings", np.char) + + +def np_add(a, b): + """NumPy's own string concatenation, spelled to work on NumPy 1.26 too.""" + return np.char.add(a, b) + + NAMES = [ "Cozy Loft With City View", "Small Single Room", @@ -37,7 +49,7 @@ def test_concat_scalar_does_not_truncate(): full = np.array(["A" * 16] * 128, dtype="= expected.dtype.itemsize @@ -56,7 +68,7 @@ def test_concat_two_arrays(names): def test_case_matches_numpy(names, func): arr = blosc2.asarray(names) got = getattr(blosc2, func)(arr).compute(strict_miniexpr=True) - expected = getattr(np.strings, func)(names) + expected = getattr(np_strings, func)(names) assert list(got[:]) == list(expected) @@ -65,14 +77,14 @@ def test_case_expansion_matches_numpy(): src = np.array(["straße", "fix"] * 64, dtype="= 0) + assert list(got[:]) == list(np_strings.find(raws, b"ell") >= 0) def test_bytes_dsl_kernel(raws): @@ -219,7 +231,7 @@ def test_wide_string_operands_match_numpy(width): assert list(got[:]) == list(values == "hello") upper = blosc2.upper(arr).compute(strict_miniexpr=True) - assert list(upper[:]) == list(np.strings.upper(values)) + assert list(upper[:]) == list(np_strings.upper(values)) def test_block_larger_than_the_eval_block(): @@ -233,4 +245,4 @@ def test_block_larger_than_the_eval_block(): assert arr.blocks[0] > 4096, "need a block wider than one miniexpr eval block" got = ("x=" + arr).compute(strict_miniexpr=True) - assert list(got[:]) == list("x=" + values) + assert list(got[:]) == list(np_add("x=", values)) From 86d466f06b1b32e1cb0875d57eaf16a18bf90d8d Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 18:58:34 +0200 Subject: [PATCH 68/86] Let nested (dotted) utf8 leaves be filtered t.where("trip.name == 'x'") raised NotImplementedError on a utf8 leaf, while the same query on a column map, since they must reach storage and the null sentinel by column name while matching the alias in the expression. Covers both the raw-byte scalar-mask route and the span driver. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 8 ++++ doc/reference/ctable.rst | 4 +- src/blosc2/ctable.py | 82 +++++++++++++++++++++++++++------------ tests/ctable/test_utf8.py | 70 +++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 27 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e304f38af..17bb52a10 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -205,6 +205,14 @@ XXX version-specific blurb XXX NUL against a `StringDType` array (`"\x00x"` and `"a\x00b"` compare fine), so every null mask would silently stop marking nulls. The default sentinel `'__BLOSC2_NULL__'` was never affected. +- **Nested (dotted) `utf8()` leaves can be filtered.** `t.where("trip.name == + 'x'")` raised `NotImplementedError` on a utf8 leaf, while the same query on a + ` str: rewritten = new_expr return rewritten, new_operands + @staticmethod + def _alias_dotted(expr: str, names: list[str], prefix: str) -> tuple[str, dict[str, str]]: + """Replace each dotted name in *expr* with ``{prefix}{i}``. + + Returns the rewritten expression and the ``alias -> original name`` + map, holding only the names that actually occurred in *expr*. + """ + rewritten = expr + aliases = {} + # Longest names first so trip.begin.lon is rewritten before trip.begin. + for i, name in enumerate(sorted((n for n in names if "." in n), key=len, reverse=True)): + alias = f"{prefix}{i}" + pattern = rf"(? tuple[str, dict[str, blosc2.NDArray | blosc2.LazyExpr]]: @@ -13044,21 +13063,18 @@ def _rewrite_nested_expression( columns are naturally addressed as dotted paths (e.g. ``trip.begin.lon``). This maps them to temporary aliases and returns rewritten expression and operand mapping. + + Only names present in *operands* are rewritten; the flavours excluded + from the operand namespace (utf8, dictionary, ...) are aliased by + whichever driver evaluates them -- see :meth:`_lazyexpr_over_cols`. """ - dotted = [name for name in operands if "." in name] - if not dotted: + rewritten, aliases = self._alias_dotted(expr, list(operands), "__nf") + if not aliases: return expr, operands - rewritten = expr new_operands = dict(operands) - # Longest names first so trip.begin.lon is rewritten before trip.begin. - for i, name in enumerate(sorted(dotted, key=len, reverse=True)): - alias = f"__nf{i}" - pattern = rf"(? list[str]: _UTF8_CMP_MIRROR: ClassVar[dict] = {"==": "==", "!=": "!=", "<=": ">=", ">=": "<=", "<": ">", ">": "<"} def _rewrite_utf8_predicates( - self, expr: str, operands: dict, utf8_names: list[str] + self, expr: str, operands: dict, utf8_names: list[str], aliases: dict[str, str] | None = None ) -> tuple[str, dict, list[str]]: """Replace ``utf8col 'literal'`` terms with precomputed masks. @@ -13163,13 +13179,17 @@ def _rewrite_utf8_predicates( names still referenced -- a name drops out only when *every* one of its occurrences was rewritten, so anything else (``startswith(name, 'x')``, ``upper(name)``) still routes to the span driver. + + Names are as they appear in *expr*; a nested leaf appears under an + alias and resolves to its column through *aliases*. """ rewritten = expr new_operands = dict(operands) remaining = [] + col_of = (aliases or {}).get ops = "|".join(re.escape(o) for o in self._UTF8_CMP_OPS) for i, name in enumerate(utf8_names): - column = self[name] + column = self[col_of(name, name)] counter = itertools.count() def repl(match: re.Match, _col=column, _i=i, _c=counter, reverse=False) -> str: @@ -13206,31 +13226,43 @@ def _lazyexpr_over_cols(self, expr: str, operands: dict, utf8_names: list[str]): """ if not utf8_names: return blosc2.lazyexpr(expr, operands) - dotted = [n for n in utf8_names if "." in n] - if dotted: - raise NotImplementedError( - f"Column {dotted[0]!r} is a nested variable-length utf8 column; " - "string expressions only support top-level utf8 columns." - ) - expr, operands, utf8_names = self._rewrite_utf8_predicates(expr, operands, utf8_names) + # utf8 columns are outside the operand namespace, so _rewrite_nested_expression + # never saw them; a nested leaf still carries its dotted name here, which + # neither miniexpr nor blosc2.lazyexpr can parse as an identifier. + expr, aliases = self._alias_dotted(expr, utf8_names, "__u8n") + renamed = {name: alias for alias, name in aliases.items()} + utf8_names = [renamed.get(name, name) for name in utf8_names] + expr, operands, utf8_names = self._rewrite_utf8_predicates(expr, operands, utf8_names, aliases) if not utf8_names: return blosc2.lazyexpr(expr, operands) - return self._utf8_span_eval(expr, operands, utf8_names) + return self._utf8_span_eval(expr, operands, utf8_names, aliases=aliases) - def _utf8_span_eval(self, expr: str, operands: dict, utf8_names: list[str], *, strict: bool = False): + def _utf8_span_eval( + self, + expr: str, + operands: dict, + utf8_names: list[str], + *, + aliases: dict[str, str] | None = None, + strict: bool = False, + ): """Evaluate *expr* over this table's utf8 columns in row spans. Thin wrapper over :func:`~blosc2._utf8_array.utf8_span_eval`; the result uses the table's *physical* length, the coordinate system every other - predicate here works in. + predicate here works in. Names in *utf8_names* are as they appear in + *expr*, which for a nested leaf is an alias -- *aliases* maps it back to + the column. """ from blosc2._utf8_array import utf8_span_eval + col_of = (aliases or {}).get + return utf8_span_eval( expr, operands, - {name: self._cols[name] for name in utf8_names}, - {name: self[name].null_value for name in utf8_names}, + {name: self._cols[col_of(name, name)] for name in utf8_names}, + {name: self[col_of(name, name)].null_value for name in utf8_names}, len(self._valid_rows), strict=strict, span_rows=self._UTF8_EXPR_SPAN, diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index addb977f9..0a6feb5ef 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -2217,3 +2217,73 @@ def test_constructors_with_string_dtype_reject_nd_and_storage_kwargs(): def test_utf8_dispatch_round_trips_through_the_conversion_pair(): out = blosc2.full(2, "hé", dtype=STRING_DTYPE) assert list(blosc2.to_utf8(blosc2.from_utf8(out))[:]) == ["hé", "hé"] + + +# --------------------------------------------------------------------------- +# Nested (dotted) utf8 leaves +# --------------------------------------------------------------------------- + + +def _nested_table(**kwargs): + """A table whose utf8 column is addressed by a dotted path. + + Two leaves under overlapping prefixes, so the longest-name-first aliasing + is exercised: rewriting "trip.who" first would corrupt "trip.begin.who". + """ + names = ["alice", "bob", "carol", "dave"] + t = CTable( + Row, + new_data={"name": names, "x": list(range(len(names)))}, + **kwargs, + ) + t.rename_column("name", "trip.begin.who") + t.add_column("trip.who", blosc2.utf8(), values=[n.upper() for n in names]) + return t + + +@pytest.mark.parametrize( + ("expr", "expected"), + [ + ('trip.begin.who == "bob"', [1]), + ('"bob" == trip.begin.who', [1]), + ('trip.begin.who != "bob"', [0, 2, 3]), + ('trip.begin.who < "c"', [0, 1]), + ('startswith(trip.begin.who, "c")', [2]), + ('upper(trip.begin.who) == "DAVE"', [3]), + ('startswith(trip.begin.who, "c") & (x > 1)', [2]), + # Both leaves at once, and the shorter name is a prefix of the longer. + ('(trip.begin.who == "bob") | (trip.who == "CAROL")', [1, 2]), + ], +) +def test_ctable_utf8_nested_leaf_filters(expr, expected): + # Dotted utf8 leaves are outside the operand namespace, so they reach the + # utf8 driver still spelled with dots -- which no expression engine parses. + t = _nested_table() + assert list(t.where(expr)["x"][:]) == expected + + +def test_ctable_utf8_nested_leaf_matches_the_flat_column(): + """A dotted name must not change the answer the same data gives flat.""" + values = ["hello", "help", "world", "zz"] + flat = make_table(values) + nested = make_table(values) + nested.rename_column("name", "trip.who") + for flat_expr, nested_expr in ( + ("name == 'hello'", 'trip.who == "hello"'), + ("startswith(name, 'hel')", 'startswith(trip.who, "hel")'), + ("name < 'w'", 'trip.who < "w"'), + ): + assert list(flat.where(flat_expr)["x"][:]) == list(nested.where(nested_expr)["x"][:]) + + +def test_ctable_utf8_nested_leaf_sum_where_and_persistence(tmp_path): + urlpath = str(tmp_path / "utf8_nested.b2z") + t = _nested_table(urlpath=urlpath, mode="w") + assert t["x"].sum(where='startswith(trip.begin.who, "c")') == 2 + t.close() + + reopened = CTable.open(urlpath, mode="r") + try: + assert list(reopened.where('trip.begin.who == "dave"')["x"][:]) == [3] + finally: + reopened.close() From eaeaaed35d2bd67f63eb5c5bcacbbcc6acfe4af9 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 18:58:43 +0200 Subject: [PATCH 69/86] Record the G2/G3/G4 withdrawal in the utf8 parity plan The plan predates the conversion pair. What shipped instead is the opposite rule -- utf8 stores and filters, fixed-width computes -- so computed columns, DSL kernels and the bare-array lift are withdrawn rather than superseded: they would buy an API indistinguishable from --- plans/utf8-string-support.md | 239 +++++++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 plans/utf8-string-support.md diff --git a/plans/utf8-string-support.md b/plans/utf8-string-support.md new file mode 100644 index 000000000..4b6b4a83c --- /dev/null +++ b/plans/utf8-string-support.md @@ -0,0 +1,239 @@ +# Compute parity for utf8 strings + +> ## Outcome: **G2, G3 and G4 withdrawn; G5 shipped.** Not superseded — decided against. +> +> The plan was written before the conversion pair existed. What shipped instead is the opposite +> rule: **utf8 stores and filters; fixed-width computes** (`f8af0714`, `5b31abe4`), with every +> compute-side refusal printing the two-line recipe that fixes it (`8e3868ba`). See +> `plans/string-flavours-assessment.md`, which measured the flavours end to end and is the +> document of record. +> +> | | verdict | why | +> |---|---|---| +> | G1 | moot | subsumed by G2, which is withdrawn | +> | **G2** computed columns | **withdrawn** | see below | +> | **G3** DSL kernels / `apply()` | **withdrawn** | same output-container problem, same recipe covers it | +> | **G4** bare `UTF8Array` | **withdrawn**, minus the two real bugs | `__eq__` fixed in `3692673f`; the wrong-path `lazyexpr` fixed in `0b486b07`. The remaining "lift the driver" work has no asked-for use case | +> | **G5** nested leaves | **shipped**, as a *query* fix | not a compute gap at all — see below | +> +> **Why G2 and G3 are withdrawn.** They would buy an API that looks identical to ` 3–5× slower (the span driver's decode + `astype` per span is unavoidable), paid for with a +> serialization hazard whose failure mode is an **unopenable table**: `_schema_dict_with_computed` +> saves `str(dtype)` and `np.dtype("StringDType()")` raises on load. The published rule is both +> cheaper and more honest — the `.astype()` the user writes *is* what the driver would have done +> silently. Reopen only on a concrete user request for utf8-typed computed columns; the ~1 week +> estimate below still stands, and the `"utf8"` dtype sentinel in §G2 is still the way to do it. +> +> **Why G5 was not withdrawn with them.** It is filed here under compute, but a nested utf8 leaf +> could not be *filtered* either — `t.where("trip.name == 'x'")` raised, while the same query on a +> ` nested columns, i.e. a hole in the rule the other four gaps were withdrawn in favour of. Fixed +> by aliasing dotted utf8 names in `_lazyexpr_over_cols`; the diagnosis in §G5 below was wrong +> about the mechanism (see the note there). + +Give `utf8()` columns (and `Utf8Array`) the same computing surface ` column` map so they can still reach storage and null sentinels. + +This is a **query** fix, not a compute one — hence shipping while G2/G3 are withdrawn. Scalar +comparisons, `startswith`/`upper`, mixed numeric predicates and `sum(where=)` all work on a dotted +utf8 leaf now, and the answers match the same data in a flat column. + +--- + +## Not gaps + +- **miniexpr.** Nothing. utf8 reaches it as fixed-width ` Date: Wed, 29 Jul 2026 18:58:44 +0200 Subject: [PATCH 70/86] Close item 5 of the assessment, and refile nested leaves The matrix filed nested (dotted) leaves under Compute, which read as another casualty of the G2/G3 decision. It was not: a dotted utf8 leaf could not be filtered either, which made "utf8 stores and filters" false for nested columns. Moved to the Query block, now green, with the mechanism and the decision written up in a new section. Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 55 +++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 521f183b5..7147123b1 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -23,6 +23,7 @@ because two of the conclusions below originally rested on it. | `where("c == 'v'")` | ✓ | ✓ | ✓ | ✓ | ✗ NotImpl | | `where("startswith(c,…)")` | ✓ | ✓ | ✓ | ✗ *Unknown symbol* | ✗ NotImpl | | `where("upper(c) == …")` | ✓ | ✓ | ✓ | ✗ *Unknown symbol* | ✗ NotImpl | +| nested (dotted) leaf in predicate | ✓ | ✓ | ✓ ¹² | ✓ | ✗ | | `sum(where=…)` | ✓ | ✓ | ✓ | ✗ | ✗ | | `sort_by` | ✓ | ✓ | ✓ | ✓ | ✗ TypeError | | `group_by` | ✓ | ✓ | ✓ | ✓ | ✗ | @@ -31,7 +32,6 @@ because two of the conclusions below originally rested on it. | `add_computed_column("'x='+c")` | ✓ | ✓ | ✗ NotImpl, routes ¹⁰ | ✗ | ✗ | | `assign(new=…)` | ✓ | ✓ | ✗ NotImpl, routes ¹⁰ | ✗ | ✗ | | `t.apply(dsl_kernel)` / `lazyudf` | ✓ | ✓ | ✗ NotImpl, routes ¹ ¹⁰ | ✗ | ✗ RuntimeError | -| nested (dotted) leaf in expr | ✓ | ✓ | ✗ NotImpl | ✗ | ✗ | | **Bare container (no CTable)** | | | | | | | `lazyexpr(expr, {a: col})` | ✓ NDArray | ✓ | ✓ span driver, returns `UTF8Array` ² | ⚠ padded ⁶ | ⚠ numpy | | `col == "scalar"` | ✓ LazyExpr | ✓ LazyExpr | ✓ bool mask ³ | ✓ bool mask ³ | ✓ bool mask ³ | @@ -313,8 +313,11 @@ never rested on these two rows, which is exactly the point the next paragraph ma made a literal→rank lookup one `searchsorted`, so `==` went 29.00 → 5.49 ms and `<` 34.57 → 5.45 ms. It was inherited from the "ordering only" misreading corrected two sections above. 4. ~~**Make the error messages route.**~~ — **done**, `8e3868ba`. See ¹⁰. -5. **Drop G2/G3/G5**, or park them behind a concrete user request. G2 in particular buys a +5. ~~**Drop G2/G3/G5**, or park them behind a concrete user request.~~ — **decided**. G2, G3 and + G4 are withdrawn and recorded as such at the top of `utf8-string-support.md`; they buy a `StringDType`-in-schema serialization hazard for a surface the conversion pair already covers. + **G5 was pulled out of that group and shipped**, because it turned out not to be a compute gap + at all — see ¹². Found while measuring, unrelated to the utf8 decision: @@ -328,6 +331,7 @@ Found while measuring, unrelated to the utf8 decision: bugs, both affecting *every* indexable dtype, neither utf8-specific. - ~~`create_index` accepts any of the five kinds on a rank-indexed column and silently builds one that is never consulted~~ — **fixed**, see §⁹. +- ~~Nested (dotted) utf8 leaves cannot be filtered~~ — **fixed**, see ¹². - Still open: `_build_lex_keys` could sort dictionary ranks instead of decoded strings (~4×), and the `where("c == 'x'")` string form still bypasses the index for both flavours. @@ -660,3 +664,50 @@ reminder that `hasattr` probes for capability make silent contracts out of missi Deliberately not done: making `UTF8Spec.dtype` return `StringDType()`. It would have to stay lazy for NumPy 1.26, `dtype is None` is load-bearing at three sites, and `Column.dtype` already reports `StringDType()` — so the win is cosmetic. + +--- + +## ¹² Nested (dotted) utf8 leaves, and the G2/G3/G4 decision + +Item 5 was supposed to be a decision, not work. Probing it turned up one thing that had to ship +first. + +**The matrix filed nested leaves under *Compute*, which read as "another casualty of the same +decision".** It was not. A dotted utf8 leaf could not be *filtered* either: + +| | `where('trip.name == "x"')` | +|---|---| +| `string()` / `bytes()` nested leaf | ✓ (and `startswith`, and computed columns) | +| `dictionary()` nested leaf | ✓ | +| `utf8()` nested leaf | ✗ `NotImplementedError` | + +utf8 was the only flavour where a dotted name could not be queried at all — a hole in the very rule +the other gaps were being withdrawn in favour of. So G5 shipped and G2/G3/G4 did not. + +**The cause was not the one `utf8-string-support.md` §G5 recorded.** That section blamed +`_rewrite_nested_expression` aliasing the name away before the driver could find it, and prescribed +carrying the alias map through. Carrying it through changes nothing: that rewrite only touches names +present in `operands`, and utf8 columns are *excluded* from the operand namespace — a +variable-length column cannot be an expression operand, which is the premise the whole span driver +exists to work around. So the dotted name never reached the rewrite and arrived at +`blosc2.lazyexpr` still spelled with dots, where it is not a parseable identifier. + +The fix aliases dotted utf8 names in `_lazyexpr_over_cols` itself, sharing one `_alias_dotted` +helper with the nested rewrite. `_rewrite_utf8_predicates` and `_utf8_span_eval` take the +`alias -> column` map, since they must still reach storage and the null sentinel by column name +while matching the alias in the expression. Both the raw-byte scalar-mask route and the span driver +are covered, including a leaf whose name is a prefix of another (`trip.who` under `trip.begin.who` — +longest-first ordering, same as the nested rewrite). + +**And the decision itself: G2, G3 and G4 are withdrawn**, recorded at the top of +`utf8-string-support.md` rather than here, so the plan cannot be picked up later without meeting the +verdict first. The short form: they would deliver an API indistinguishable from ` Date: Wed, 29 Jul 2026 19:08:58 +0200 Subject: [PATCH 71/86] Sort dictionary columns by rank instead of decoded strings A row's alphabetical rank orders exactly as its decoded value does -- the basis of the FULL rank index -- so _build_lex_keys can hand lexsort an int32 key and skip both the decode and lexsort's string comparisons. The code -> rank map is extracted as _dict_code_to_rank so the sort and the index builder cannot drift apart. 200k rows, cardinality 5000: key construction 106.2 -> 21.4 ms, sort_by(view=True) 246.7 -> 156.9 ms, descending 273.5 -> 156.2 ms (an object key could not be negated, so descending went through a double argsort that an int32 key does not need). _sorted_small_copy_from_live_positions held a second copy of the key builder with the same decode, a narrower dtype list (no StringDType keys) and a KeyError for computed sort columns, and it re-read the column after already gathering the codes. It now calls _build_lex_keys with the arrays it gathered, which removes the duplicate. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 7 +++ src/blosc2/ctable.py | 78 ++++++++++---------------- src/blosc2/ctable_indexing.py | 20 ++++++- tests/ctable/test_dictionary_column.py | 59 +++++++++++++++++++ 4 files changed, 113 insertions(+), 51 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 17bb52a10..79268f4cc 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -142,6 +142,13 @@ XXX version-specific blurb XXX lexsort-based `sort_by` cost O(N) decompressions. At 1M rows an unindexed `sort_by` drops from 236 s to 713 ms, and a full column read from 44 s (at 200k rows) to 193 ms. +- **`sort_by` on a dictionary column sorts int32 ranks, not decoded + strings.** A row's alphabetical rank orders exactly as its value does — the + trick the FULL index already used — so the sort key needs neither the decode + nor lexsort's string comparisons. Key construction drops from 106 ms to 21 ms + per 200k rows (cardinality 5000) and `sort_by(view=True)` from 247 ms to + 157 ms. The filtered small-copy path, which had its own copy of the key + builder, now shares this one and picks up the same speedup. - **`kind=BUCKET` indexes no longer cost more than the scan they replace.** Scattered matches were read one bucket run at a time, re-decompressing the same blocks many times, and the planner measured selectivity in buckets while diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 082b0afda..669ec9dd4 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -11645,13 +11645,22 @@ def _build_lex_keys( ascending: list[bool], live_pos: np.ndarray, n: int, + gathered: dict[str, np.ndarray] | None = None, ) -> list[np.ndarray]: """Build the key list for np.lexsort (innermost = last = primary key). For nullable columns a null-indicator key (0=non-null, 1=null) is inserted immediately after the value key, making it more significant. This ensures nulls sort last regardless of ascending/descending order. + + *gathered* lets a caller that has already read the columns at + *live_pos* hand them over instead of paying for a second gather; a + dictionary column is expected there as its **codes**, which is what it + is cheap to gather. """ + from blosc2.ctable_indexing import _dict_code_to_rank + + gathered = gathered or {} lex_keys = [] for name, asc in zip(reversed(cols), reversed(ascending), strict=True): cc = self._computed_cols.get(name) @@ -11663,14 +11672,24 @@ def _build_lex_keys( else: is_dict_col = col_info is not None and self._is_dictionary_column(col_info) if is_dict_col: - # Sort dictionary columns by decoded string values. - decoded = self._cols[name][live_pos] - raw = np.array(decoded, dtype=object) - # Replace None with placeholder so lexsort never compares None. - # Null indicator key (below) already places nulls last. - raw[raw == None] = "" # noqa: E711 + # Sort a dictionary column by the alphabetical rank of each + # row's code -- the same trick the FULL index plays. Ranks + # order exactly as the decoded values do, and sorting int32 + # skips both the decode and lexsort's string comparisons. + dict_col = self._cols[name] + dictionary = list(dict_col.dictionary) + code_to_rank = _dict_code_to_rank(dictionary) + raw_codes = gathered[name] if name in gathered else dict_col.codes[live_pos] + codes = np.asarray(raw_codes, dtype=np.int32) + # The null code is reserved (-1), not a dictionary entry, so + # it cannot be looked up; nulls take the largest rank. The + # null indicator key below is what actually places them. + is_null = codes == col_info.spec.null_code + raw = np.empty(len(codes), dtype=np.int32) + raw[~is_null] = code_to_rank[codes[~is_null]] + raw[is_null] = np.int32(len(dictionary)) else: - raw = self._cols[name][live_pos] + raw = gathered[name] if name in gathered else self._cols[name][live_pos] nv = getattr(col_info.spec, "null_value", None) if col_info else None # Value key @@ -11689,10 +11708,7 @@ def _build_lex_keys( # Null indicator key — more significant than the value key above, # so nulls always sort last (0 before 1 → non-null before null). if is_dict_col and col_info.spec.nullable: - null_code = col_info.spec.null_code - codes_at_pos = np.asarray(self._cols[name].codes[live_pos], dtype=np.int32) - null_ind = (codes_at_pos == null_code).astype(np.intp) - lex_keys.append(null_ind) + lex_keys.append(is_null.astype(np.intp)) elif nv is not None: if isinstance(nv, float) and np.isnan(nv): null_ind = np.isnan(raw).astype(np.intp) @@ -11966,43 +11982,9 @@ def _sorted_small_copy_from_live_positions( else: gathered[col.name] = arr[live_pos] - lex_keys = [] - for name, asc in zip(reversed(cols), reversed(ascending), strict=True): - col_info = self._schema.columns_by_name.get(name) - is_dict_col = col_info is not None and self._is_dictionary_column(col_info) - if is_dict_col: - raw = np.array(self._cols[name][live_pos], dtype=object) - # Replace None with placeholder so lexsort never compares None. - raw[raw == None] = "" # noqa: E711 - else: - raw = gathered[name] - - if not asc: - if raw.dtype.kind in "USO": - rank = np.argsort(np.argsort(raw, kind="stable"), kind="stable") - lex_keys.append((n - 1 - rank).astype(np.intp)) - elif np.issubdtype(raw.dtype, np.unsignedinteger): - lex_keys.append(-raw.astype(np.int64)) - else: - lex_keys.append(-raw) - else: - lex_keys.append(raw) - - if is_dict_col and col_info.spec.nullable: - null_code = col_info.spec.null_code - codes_at_pos = np.asarray(self._cols[name].codes[live_pos], dtype=np.int32) - null_ind = (codes_at_pos == null_code).astype(np.intp) - lex_keys.append(null_ind) - else: - nv = getattr(col_info.spec, "null_value", None) if col_info else None - if nv is not None: - if isinstance(nv, float) and np.isnan(nv): - null_ind = np.isnan(raw).astype(np.intp) - else: - null_ind = (raw == nv).astype(np.intp) - lex_keys.append(null_ind) - - order = np.lexsort(lex_keys) + # The gather above already read every column at live_pos, dictionary + # columns as codes -- exactly what the key builder wants. + order = np.lexsort(self._build_lex_keys(cols, ascending, live_pos, n, gathered)) result = self._empty_copy(capacity=n) for col in self._schema.columns: col_name = col.name diff --git a/src/blosc2/ctable_indexing.py b/src/blosc2/ctable_indexing.py index 20019df16..9f885a655 100644 --- a/src/blosc2/ctable_indexing.py +++ b/src/blosc2/ctable_indexing.py @@ -58,6 +58,22 @@ def __init__(self): self.vlmeta = _FakeVlMeta() +def _dict_code_to_rank(dictionary) -> np.ndarray: + """``code -> alphabetical rank`` lookup for a dictionary column. + + Sorting by rank is sorting by decoded value, which is what lets an int32 + array stand in for the strings — in the FULL index (:class:`_DictRankWrapper`) + and in ``CTable._build_lex_keys``. The reserved null code is *not* a + dictionary entry, so callers assign nulls a rank of their own (``len``, + the largest, so nulls sort last). + """ + n_entries = len(dictionary) + order = np.argsort(dictionary, kind="stable") + code_to_rank = np.empty(n_entries, dtype=np.int32) + code_to_rank[order] = np.arange(n_entries, dtype=np.int32) + return code_to_rank + + def _dict_rank_hash(dictionary) -> str: """Stable hash of a dictionary's entries (code position + value). @@ -887,9 +903,7 @@ def create_index( # noqa: C901 n_live = self._n_rows if self._n_rows is not None else len(self._valid_rows) dictionary = list(dict_col.dictionary) n_entries = len(dictionary) - order = np.argsort(dictionary, kind="stable") - code_to_rank = np.empty(n_entries, dtype=np.int32) - code_to_rank[order] = np.arange(n_entries, dtype=np.int32) + code_to_rank = _dict_code_to_rank(dictionary) null_code = dict_col.spec.null_code null_rank = np.int32(n_entries) # Hash for staleness detection. diff --git a/tests/ctable/test_dictionary_column.py b/tests/ctable/test_dictionary_column.py index 0770e4b1a..757744619 100644 --- a/tests/ctable/test_dictionary_column.py +++ b/tests/ctable/test_dictionary_column.py @@ -10,6 +10,7 @@ from dataclasses import dataclass +import numpy as np import pytest import blosc2 @@ -686,3 +687,61 @@ def counting_hash(dictionary): finally: ci._dict_rank_hash = _dict_rank_hash assert calls == 0, "value epoch was unchanged, so no hash should have been needed" + + +def test_sort_by_keys_on_ranks_not_decoded_strings(): + """sort_by must not decode a dictionary column to sort it. + + Sorting by alphabetical rank is sorting by decoded value, so the key can + stay int32 -- which skips both the decode and lexsort's string + comparisons. Correctness alone would not notice the difference, so + assert on the key dtype as well as the order. + """ + + @dataclass + class Row: + c: str = blosc2.field(blosc2.dictionary()) + x: int = blosc2.field(blosc2.int64()) + + values = ["delta", "alpha", "Zeta", "beta", "alpha"] + t = CTable(Row, new_data={"c": values, "x": list(range(len(values)))}) + + live = np.arange(len(values)) + keys = t._build_lex_keys(["c"], [True], live, len(values)) + assert keys[0].dtype == np.int32 + + # Ranks must order exactly as Python orders the decoded strings. + assert list(t.sort_by("c")["c"][:]) == sorted(values) + assert list(t.sort_by("c", ascending=False)["c"][:]) == sorted(values, reverse=True) + + +def test_sort_by_dictionary_nulls_and_multiple_keys(): + """Nulls sort last in both directions, and rank keys compose with others.""" + + @dataclass + class Row: + c: str = blosc2.field(blosc2.dictionary(nullable=True)) + x: int = blosc2.field(blosc2.int64()) + + values = ["b", None, "a", None, "b"] + t = CTable(Row, new_data={"c": values, "x": [0, 1, 2, 3, 4]}) + + assert list(t.sort_by("c")["c"][:]) == ["a", "b", "b", None, None] + assert list(t.sort_by("c", ascending=False)["c"][:]) == ["b", "b", "a", None, None] + # Secondary key breaks the "b" tie; nulls still trail. + assert list(t.sort_by(["c", "x"], [True, False])["x"][:]) == [2, 4, 0, 3, 1] + + +def test_sort_by_dictionary_view_and_small_copy_agree(): + """The filtered small-copy path builds its keys the same way sort_by does.""" + + @dataclass + class Row: + c: str = blosc2.field(blosc2.dictionary(nullable=True)) + x: int = blosc2.field(blosc2.int64()) + + values = ["b", None, "a", "c", "b", None, "a"] + t = CTable(Row, new_data={"c": values, "x": list(range(len(values)))}) + filtered = t[t.x > 1] # small enough to take _sorted_small_copy_from_live_positions + assert list(filtered.sort_by("c")["c"][:]) == ["a", "a", "b", "c", None] + assert list(filtered.sort_by("c", ascending=False)["c"][:]) == ["c", "b", "a", "a", None] From f5838d54b1a55d6f4acfef8e2ba3161b494bdabf Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 19:08:59 +0200 Subject: [PATCH 72/86] Record the dictionary rank sort in the assessment Co-Authored-By: Claude Opus 5 --- plans/string-flavours-assessment.md | 51 +++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 6 deletions(-) diff --git a/plans/string-flavours-assessment.md b/plans/string-flavours-assessment.md index 7147123b1..313a2b080 100644 --- a/plans/string-flavours-assessment.md +++ b/plans/string-flavours-assessment.md @@ -181,10 +181,10 @@ Also note `group_by` is *fastest* on dictionary (81 ms vs 217 ms for ` Date: Wed, 29 Jul 2026 19:20:22 +0200 Subject: [PATCH 73/86] Address the Copilot review on PR #684 Three findings, all real: - @blosc2.jit forwarded every decorator kwarg to blosc2.asarray() when the traced function returned a NumPy array, so combining a storage kwarg with an execution-tuning one -- @blosc2.jit(jit=False, cparams=...) -- raised instead of returning an NDArray. Only storage kwargs go there now; the function has already run, so there is nothing left to tune. The sibling compute() call keeps taking all of them on purpose: compute() names fp_accuracy while lazyudf() does not, so stripping them would drop it. - The mandelbrot benchmark's header still said any non-None jit() kwarg flips the return container. Only storage kwargs do. - _fill_me_udata leaked np_data/np_typesizes on every allocation-failure exit after the one that allocates them. All the exits now go through one _free_me_udata_tables() helper, so a table added later cannot be missed by some of them again. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 6 ++++++ bench/ndarray/jit-dsl-mandelbrot.py | 8 ++++--- src/blosc2/blosc2_ext.pyx | 33 ++++++++++++++++++----------- src/blosc2/proxy.py | 10 +++++++-- tests/ndarray/test_jit.py | 16 ++++++++++++++ 5 files changed, 56 insertions(+), 17 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 79268f4cc..a7cd28b62 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -166,6 +166,12 @@ XXX version-specific blurb XXX ### Bug fixes +- **`@blosc2.jit` raised when a storage kwarg and an execution-tuning kwarg + were combined** and the decorated function returned a NumPy array — + `@blosc2.jit(jit=False, cparams=...)` ended in + `blosc2.asarray(retval, jit=False, ...)`, which rejects the tuning kwargs. + Only storage kwargs reach `asarray()` now; the function has already run, so + there is nothing left to tune. - **A DSL kernel over a utf8 column registered as a computed column, then broke the table.** `add_computed_column(name, kernel, inputs=["utf8_col"])` was accepted, after which every read of that column *and* `str(table)` diff --git a/bench/ndarray/jit-dsl-mandelbrot.py b/bench/ndarray/jit-dsl-mandelbrot.py index 6e9dfd5a4..f00317b9a 100644 --- a/bench/ndarray/jit-dsl-mandelbrot.py +++ b/bench/ndarray/jit-dsl-mandelbrot.py @@ -12,9 +12,11 @@ # doc/guides/optimization_tips.md ("Let @blosc2.jit compile control flow # instead of tracing it"). # -# Return paths are equalized (both calls end in a plain NumPy array): any -# non-None jit() kwarg flips the return from `retval[()]` to `.compute()`, -# which would otherwise skew the comparison. +# Return paths are equalized (both calls end in a plain NumPy array): a storage +# kwarg (cparams/chunks/urlpath/...) flips the return from `retval[()]` to +# `.compute()`, i.e. to an NDArray, which would otherwise skew the comparison. +# Execution-tuning kwargs (jit/jit_backend/fp_accuracy) do not, so `@blosc2.jit` +# is used bare here. from __future__ import annotations diff --git a/src/blosc2/blosc2_ext.pyx b/src/blosc2/blosc2_ext.pyx index 64342571d..3fc06cd8b 100644 --- a/src/blosc2/blosc2_ext.pyx +++ b/src/blosc2/blosc2_ext.pyx @@ -1027,6 +1027,22 @@ def me_output_dtype(expression, operands): free(variables) +cdef inline void _free_me_udata_tables(me_udata* udata, b2nd_array_t** inputs_, + uint8_t** np_data, int32_t* np_typesizes): + """Release the per-input tables and the udata block itself. + + ``_fill_me_udata`` has half a dozen allocation-failure exits; routing them + all through here is what keeps the tables from drifting apart, which they + already had once -- ``np_data``/``np_typesizes`` arrived later and every + exit but the first kept freeing only ``inputs``. ``free(NULL)`` is a no-op, + so no exit needs to know which tables it got as far as allocating. + """ + free(inputs_) + free(np_data) + free(np_typesizes) + free(udata) + + cdef inline str _me_compile_status_name(int rc): if rc == ME_COMPILE_SUCCESS: return "ME_COMPILE_SUCCESS" @@ -4155,10 +4171,7 @@ cdef class NDArray: np_data = calloc(ninputs, sizeof(uint8_t*)) np_typesizes = calloc(ninputs, sizeof(int32_t)) if np_data == NULL or np_typesizes == NULL: - free(inputs_) - free(np_data) - free(np_typesizes) - free(udata) + _free_me_udata_tables(udata, inputs_, np_data, np_typesizes) raise MemoryError("Cannot allocate miniexpr raw-input tables") for i, operand in enumerate(operands): if isinstance(operand, np.ndarray): @@ -4176,8 +4189,7 @@ cdef class NDArray: if ninputs > 0: input_chunk_caches = calloc(ninputs, sizeof(me_input_cache_s)) if input_chunk_caches == NULL: - free(inputs_) - free(udata) + _free_me_udata_tables(udata, inputs_, np_data, np_typesizes) raise MemoryError("Cannot allocate miniexpr chunk caches") for i in range(ninputs): input_chunk_caches[i].nchunk = -1 @@ -4191,8 +4203,7 @@ cdef class NDArray: if input_chunk_caches[i].ready_lock != NULL: PyThread_free_lock(input_chunk_caches[i].ready_lock) free(input_chunk_caches) - free(inputs_) - free(udata) + _free_me_udata_tables(udata, inputs_, np_data, np_typesizes) raise MemoryError("Cannot allocate miniexpr chunk cache state lock") input_chunk_caches[i].ready_lock = PyThread_allocate_lock() if input_chunk_caches[i].ready_lock == NULL: @@ -4205,8 +4216,7 @@ cdef class NDArray: if input_chunk_caches[i].ready_lock != NULL: PyThread_free_lock(input_chunk_caches[i].ready_lock) free(input_chunk_caches) - free(inputs_) - free(udata) + _free_me_udata_tables(udata, inputs_, np_data, np_typesizes) raise MemoryError("Cannot allocate miniexpr chunk cache ready lock") udata.input_chunk_caches = input_chunk_caches eval_params = malloc(sizeof(me_eval_params)) @@ -4217,8 +4227,7 @@ cdef class NDArray: if input_chunk_caches[i].ready_lock != NULL: PyThread_free_lock(input_chunk_caches[i].ready_lock) free(input_chunk_caches) - free(inputs_) - free(udata) + _free_me_udata_tables(udata, inputs_, np_data, np_typesizes) raise MemoryError("Cannot allocate miniexpr eval params") eval_params.disable_simd = False eval_params.simd_ulp_mode = ME_SIMD_ULP_3_5 if fp_accuracy == blosc2.FPAccuracy.MEDIUM else ME_SIMD_ULP_1 diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index 2be7b02e4..80d08d81f 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -1073,6 +1073,9 @@ def dsl_wrapper(*args, **func_kwargs): return out if storage_kwargs and any(v is not None for v in storage_kwargs.values()): + # Execution-tuning kwargs go along too: compute() names all three, + # while lazyudf() above only names jit/jit_backend, so fp_accuracy + # would otherwise be dropped on this path. return lexpr.compute(**decorator_kwargs) return lexpr[()] @@ -1270,8 +1273,11 @@ def wrapper(*args, **func_kwargs): # If it is a numpy array, return it as is if isinstance(retval, np.ndarray): if storage_kwargs and any(v is not None for v in storage_kwargs.values()): - # But if storage kwargs are provided, return a NDArray instead - return blosc2.asarray(retval, **kwargs) + # But if storage kwargs are provided, return a NDArray instead. + # Only storage kwargs: asarray() rejects the execution-tuning + # ones, and there is nothing left to tune -- the function has + # already run. + return blosc2.asarray(retval, **storage_kwargs) return retval # In some instances, the return value is not a LazyExpr diff --git a/tests/ndarray/test_jit.py b/tests/ndarray/test_jit.py index ba09af30b..df0057f3b 100644 --- a/tests/ndarray/test_jit.py +++ b/tests/ndarray/test_jit.py @@ -205,3 +205,19 @@ def f(a, b): assert isinstance(res, blosc2.NDArray) assert res.schunk.cparams.clevel == 2 np.testing.assert_allclose(res[:], a * 2.0 + b) + + +def test_jit_numpy_return_with_storage_and_tuning_kwargs(): + # A traced function whose return is already a NumPy array takes the + # asarray() branch, which accepts storage kwargs only -- forwarding the + # execution-tuning ones there raised instead of returning an NDArray. + @blosc2.jit(jit=False, cparams=blosc2.CParams(clevel=2)) + def f(a, b): + return np.sum(a * 2.0 + b, axis=0) + + a = np.arange(1000, dtype=np.float64).reshape(10, 100) + b = np.arange(1000, dtype=np.float64).reshape(10, 100) * 0.5 + res = f(a, b) + assert isinstance(res, blosc2.NDArray) + assert res.schunk.cparams.clevel == 2 + np.testing.assert_allclose(res[:], np.sum(a * 2.0 + b, axis=0)) From de99ca811efde6a291d9227c146419941baa91eb Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 19:43:34 +0200 Subject: [PATCH 74/86] Keep test names under 50 characters 346 of 7164 defs were over 50 chars, every one of them a test -- the longest shipped name is 46. The names had become failure messages, spelling out the whole test matrix in the identifier. Trimmed the redundancy first (a prefix echoing the module, "column" for "col", "expression" for "expr"), then the trailing clause where it only restated the assertion. Where a rename dropped something the body does not otherwise say -- "when threads forced", "and honors explicit cparams" -- it moved into a one-line docstring rather than vanishing. Also collapsed the four test_batcharray_guess_items_per_block_uses_* tests into one parametrized test: they were a parametrize table written out longhand, four functions differing only in clevel and payload size. Test count is unchanged (7922 collected before and after). Co-Authored-By: Claude Opus 5 --- tests/b2view/test_cli.py | 4 +- tests/b2view/test_group.py | 4 +- tests/b2view/test_plot_model.py | 4 +- tests/b2view/test_sort.py | 2 +- tests/ctable/test_arrow_interop.py | 4 +- tests/ctable/test_column.py | 8 +- tests/ctable/test_column_ndarray_like.py | 2 +- tests/ctable/test_column_slice_fastpath.py | 2 +- tests/ctable/test_csv_interop.py | 2 +- tests/ctable/test_ctable_computed_cols.py | 8 +- tests/ctable/test_ctable_dataclass_schema.py | 2 +- tests/ctable/test_ctable_indexing.py | 43 +++---- tests/ctable/test_ctable_ndarray_columns.py | 13 +- tests/ctable/test_ctable_take.py | 8 +- tests/ctable/test_dictionary_column.py | 6 +- tests/ctable/test_getitem_access.py | 10 +- tests/ctable/test_groupby.py | 38 +++--- tests/ctable/test_nested_access_storage.py | 8 +- tests/ctable/test_nested_metadata_root.py | 6 +- tests/ctable/test_null_expressions.py | 16 +-- tests/ctable/test_nullable.py | 10 +- tests/ctable/test_object_spec.py | 2 +- tests/ctable/test_parquet_interop.py | 17 +-- tests/ctable/test_schema_mutations.py | 14 +-- tests/ctable/test_schema_specs.py | 2 +- tests/ctable/test_schema_validation.py | 6 +- tests/ctable/test_sort_by.py | 6 +- tests/ctable/test_table_persistency.py | 2 +- tests/ctable/test_utf8.py | 118 +++++++++---------- tests/ctable/test_varlen_columns.py | 2 +- tests/ctable/test_vlstring_vlbytes.py | 12 +- tests/ctable/test_where_expressions.py | 6 +- tests/ndarray/test_dsl_kernels.py | 70 +++++------ tests/ndarray/test_getitem.py | 15 +-- tests/ndarray/test_indexing.py | 73 ++++++------ tests/ndarray/test_jit.py | 6 +- tests/ndarray/test_jit_dsl_dispatch.py | 22 ++-- tests/ndarray/test_lazyexpr.py | 6 +- tests/ndarray/test_linalg.py | 13 +- tests/ndarray/test_ndarray.py | 7 +- tests/ndarray/test_proxy.py | 2 +- tests/ndarray/test_slice.py | 12 +- tests/test_b2view_model.py | 6 +- tests/test_batch_array.py | 62 ++++------ tests/test_dict_store.py | 6 +- tests/test_group_reduce.py | 6 +- tests/test_list_array.py | 6 +- tests/test_locking.py | 2 +- tests/test_objectarray.py | 10 +- tests/test_pandas_udf_engine.py | 20 ++-- tests/test_proxy_schunk.py | 2 +- tests/test_python_blosc.py | 2 +- tests/test_random.py | 4 +- tests/test_tree_store.py | 8 +- 54 files changed, 374 insertions(+), 373 deletions(-) diff --git a/tests/b2view/test_cli.py b/tests/b2view/test_cli.py index 7c877e66d..9b7dd7f5f 100644 --- a/tests/b2view/test_cli.py +++ b/tests/b2view/test_cli.py @@ -39,14 +39,14 @@ def test_download_skipped_when_file_already_in_cwd(): assert info_url is None -def test_download_urls_keep_relative_path_dest_is_basename(): +def test_download_url_dest_is_basename(): urlpath, url, info_url = resolve_source(None, "sub/dir/bundle.b2z", exists=lambda p: False) assert urlpath == "bundle.b2z" assert url == DOWNLOAD_BASE_URL + "sub/dir/bundle.b2z" assert info_url == INFO_BASE_URL + "sub/dir/bundle.b2z" -def test_download_and_positional_are_mutually_exclusive(): +def test_download_and_positional_exclusive(): with pytest.raises(ValueError, match="cannot be combined"): resolve_source("local.b2z", "foo.b2z") diff --git a/tests/b2view/test_group.py b/tests/b2view/test_group.py index cd8c30314..8affbd314 100644 --- a/tests/b2view/test_group.py +++ b/tests/b2view/test_group.py @@ -169,7 +169,7 @@ def test_group_sort_noop_when_not_grouped(group_store): assert browser.get_group_sort("/ctable") is None -def test_group_bars_categorical_is_bar_sorted_desc_and_capped(group_store): +def test_group_bars_categorical_sorted_and_capped(group_store): """A dictionary key yields capped bars ranked by the aggregate descending.""" path, _ = group_store with StoreBrowser(path) as browser: @@ -195,7 +195,7 @@ def test_group_bars_numeric_is_line_pareto_by_default(group_store): assert "rank" in bars["xlabel"] -def test_group_bars_numeric_sorted_by_key_uses_key_on_x(group_store): +def test_group_bars_numeric_sorted_by_key(group_store): """Sorting a numeric-key result by the key puts key values on X in that order.""" path, _ = group_store with StoreBrowser(path) as browser: diff --git a/tests/b2view/test_plot_model.py b/tests/b2view/test_plot_model.py index 38017a828..44d272db8 100644 --- a/tests/b2view/test_plot_model.py +++ b/tests/b2view/test_plot_model.py @@ -99,7 +99,7 @@ def test_stream_envelope_matches_full_read_ctable(plot_store, monkeypatch): _assert_exact(env, vals) -def test_stream_envelope_captures_spike_a_sample_would_miss(plot_store, monkeypatch): +def test_stream_envelope_captures_spike(plot_store, monkeypatch): path, vals = plot_store _force_stream(monkeypatch) with StoreBrowser(path) as browser: @@ -222,7 +222,7 @@ def test_read_series_clamps_range(plot_store): assert clamped["y"].shape == (N,) -def test_locked_row_window_confines_plot_and_read_series(plot_store): +def test_locked_row_window_confines_plot(plot_store): """A locked row window (the 'v' action) takes precedence over the full series in both plot_series and read_series, matching preview()/read_cell() (PR #663 review): a plot/hi-res of a windowed CTable shows only its rows.""" diff --git a/tests/b2view/test_sort.py b/tests/b2view/test_sort.py index f59448b04..ba721e321 100644 --- a/tests/b2view/test_sort.py +++ b/tests/b2view/test_sort.py @@ -185,7 +185,7 @@ async def _wait_for_table(pilot) -> None: @pytest.mark.asyncio @pytest.mark.tui -async def test_sort_key_opens_screen_applies_and_escape_clears(sort_store): +async def test_sort_key_applies_and_escape_clears(sort_store): path, _, _ = sort_store app = B2ViewApp(path, start_panel="data") async with app.run_test(size=TERM_SIZE) as pilot: diff --git a/tests/ctable/test_arrow_interop.py b/tests/ctable/test_arrow_interop.py index ed687c9a6..044686343 100644 --- a/tests/ctable/test_arrow_interop.py +++ b/tests/ctable/test_arrow_interop.py @@ -351,7 +351,7 @@ def test_from_arrow_string_fixed_width_with_max_length(): assert t["name"][:].tolist() == ["hi", "hello world", "!"] -def test_from_arrow_list_struct_nullable_values_roundtrip(): +def test_from_arrow_list_struct_nullable(): nutrient_type = pa.struct( [ pa.field("name", pa.string()), @@ -454,7 +454,7 @@ def test_from_arrow_dictionary_codes_use_aligned_grid(): assert list(t["c"][:5]) == c.to_pylist()[:5] -def test_to_arrow_dictionary_multi_batch_with_deletions(): +def test_to_arrow_dict_multi_batch_deletions(): """Dictionary-column export across several batches, with holes in the live-row mask from a deletion, still maps each batch to the correct physical positions. diff --git a/tests/ctable/test_column.py b/tests/ctable/test_column.py index 33d922cc0..8d880206e 100644 --- a/tests/ctable/test_column.py +++ b/tests/ctable/test_column.py @@ -439,7 +439,7 @@ def test_sum_empty_filtered_view_returns_zero(): assert t[t.id < 0]["id"].sum() == 0 -def test_sum_where_skips_valid_rows_mask_when_all_rows_visible(): +def test_sum_where_skips_mask_when_all_visible(): t = CTable(Row, new_data=DATA20, expected_size=len(DATA20)) mask = t["id"]._lazy_nonnull_mask(where=t["score"] < 100) assert mask.expression == "(o0 < 100)" @@ -904,7 +904,7 @@ def test_column_repr_shows_preview_values(): assert "..." in r -def test_info_omits_capacity_and_read_only_for_in_memory_table(): +def test_info_omits_capacity_for_in_memory(): t = CTable(Row, new_data=DATA20) info = repr(t.info) assert "capacity" not in info @@ -1077,7 +1077,7 @@ def test_ctable_setitem_view_raises(): view["score"] = np.zeros(len(view)) -def test_column_setitem_ndarray_fast_path_on_disk_table(tmp_path): +def test_setitem_ndarray_fast_path_on_disk(tmp_path): """Fast path fires for a disk-opened table (not just freshly-built in-memory tables).""" n = 60 urlpath = str(tmp_path / "tbl.b2") @@ -1113,7 +1113,7 @@ class R: np.testing.assert_allclose(t["val"][:], np.arange(n, dtype=np.float64) * 3.14) -def test_column_setitem_blosc2_ndarray_no_holes_uneven_chunks(): +def test_setitem_b2_ndarray_no_holes_uneven(): """Fast path works when nrows is not a multiple of chunk_size.""" n = 70 diff --git a/tests/ctable/test_column_ndarray_like.py b/tests/ctable/test_column_ndarray_like.py index d12de3e76..847c16fb4 100644 --- a/tests/ctable/test_column_ndarray_like.py +++ b/tests/ctable/test_column_ndarray_like.py @@ -26,7 +26,7 @@ def test_column_logical_metadata(): assert view.x.size == 3 -def test_column_boolean_operators_build_lazy_expressions(): +def test_boolean_operators_build_lazy_exprs(): t = blosc2.CTable(Row, new_data=DATA) view = t.where(t.flag & (t.x > 0)) diff --git a/tests/ctable/test_column_slice_fastpath.py b/tests/ctable/test_column_slice_fastpath.py index 48c52e243..d8465528f 100644 --- a/tests/ctable/test_column_slice_fastpath.py +++ b/tests/ctable/test_column_slice_fastpath.py @@ -148,7 +148,7 @@ def test_deletions_use_position_path_and_stay_correct(key): @pytest.mark.parametrize("key", [np.s_[::5], np.s_[::-2], np.s_[::-1]]) -def test_filtered_view_uses_position_path_and_stays_correct(key): +def test_filtered_view_uses_position_path(key): table, arr = _make() view = table.where("a >= 500") expected = arr["b"][arr["a"] >= 500] diff --git a/tests/ctable/test_csv_interop.py b/tests/ctable/test_csv_interop.py index 9a2063c95..8a904d807 100644 --- a/tests/ctable/test_csv_interop.py +++ b/tests/ctable/test_csv_interop.py @@ -356,7 +356,7 @@ def test_from_csv_ndarray_wrong_shape_raises(tmp_csv): CTable.from_csv(tmp_csv, NdarrayRow) -def test_from_csv_nonnullable_ndarray_empty_cell_raises(tmp_csv): +def test_csv_nonnullable_empty_cell_raises(tmp_csv): with open(tmp_csv, "w") as f: f.write("id,embedding\n") f.write("1,\n") diff --git a/tests/ctable/test_ctable_computed_cols.py b/tests/ctable/test_ctable_computed_cols.py index 9df61616b..9c90040ef 100644 --- a/tests/ctable/test_ctable_computed_cols.py +++ b/tests/ctable/test_ctable_computed_cols.py @@ -164,7 +164,7 @@ def test_computed_column_where_via_col(): assert len(view) == 3 # 9, 16, 25 -def test_getitem_boolean_lazyexpr_matches_where_for_computed_column(): +def test_getitem_bool_lazyexpr_matches_where(): t = _make_invoice_table(5) t.add_computed_column("total", lambda cols: cols["price"] * cols["qty"]) expr = t.total >= 9 @@ -499,7 +499,7 @@ def test_materialize_computed_column_extend_autofill(): np.testing.assert_allclose(t["total_stored"][:], [1.0, 4.0, 9.0, 16.0]) -def test_materialize_computed_column_explicit_append_value_wins(): +def test_materialize_explicit_append_wins(): t = _make_invoice_table(2) t.add_computed_column("total", lambda cols: cols["price"] * cols["qty"]) t.materialize_computed_column("total", new_name="total_stored") @@ -745,7 +745,7 @@ def test_materialize_computed_column_open_roundtrip(tmp_path): assert t2.index("total_stored").kind == "full" -def test_materialize_computed_column_open_append_autofill(tmp_path): +def test_materialize_open_append_autofill(tmp_path): path = str(tmp_path / "tbl") t = CTable(Invoice, [(1.0, 1, 0.1), (2.0, 2, 0.1)], urlpath=path, mode="w") t.add_computed_column("total", lambda cols: cols["price"] * cols["qty"]) @@ -1066,7 +1066,7 @@ def patched(self, expr): t.add_computed_column("total", lambda cols: cols["price"] * cols["qty"]) -def test_add_computed_column_malformed_expression_raises(monkeypatch): +def test_add_computed_malformed_expr_raises(monkeypatch): """add_computed_column raises ValueError when the expression string cannot be re-parsed.""" t = _make_invoice_table() diff --git a/tests/ctable/test_ctable_dataclass_schema.py b/tests/ctable/test_ctable_dataclass_schema.py index 8a2741eae..28e8a2116 100644 --- a/tests/ctable/test_ctable_dataclass_schema.py +++ b/tests/ctable/test_ctable_dataclass_schema.py @@ -165,7 +165,7 @@ class ArrayRow: assert np.array_equal(reopened.matrix[:], data) -def test_fixed_shape_ndarray_column_rejects_wrong_shape(): +def test_fixed_shape_ndarray_rejects_wrong_shape(): @dataclass class ArrayRow: matrix: np.ndarray = blosc2.field(blosc2.ndarray((2, 3), dtype=np.float64)) # noqa: RUF009 diff --git a/tests/ctable/test_ctable_indexing.py b/tests/ctable/test_ctable_indexing.py index 1d54297c4..c1f0078c1 100644 --- a/tests/ctable/test_ctable_indexing.py +++ b/tests/ctable/test_ctable_indexing.py @@ -103,7 +103,7 @@ def test_where_with_index_matches_scan_in_memory(): @pytest.mark.heavy -def test_indexed_where_view_sort_by_reuses_cached_live_positions(monkeypatch): +def test_indexed_where_sort_by_reuses_live_pos(monkeypatch): t = _make_table(200) t.create_index("id", kind=blosc2.IndexKind.FULL) @@ -128,7 +128,7 @@ def test_create_expression_index_in_memory(): @pytest.mark.heavy -def test_where_with_expression_index_matches_scan_in_memory(): +def test_where_expr_index_matches_scan(): t = _make_table(200) t.create_index(expression="value * category", kind=blosc2.IndexKind.FULL, name="vc") result_idx = t.where((t._cols["value"] * t._cols["category"]) >= 150) @@ -196,7 +196,7 @@ def test_stale_on_column_assign_in_memory(): assert t.index("id").stale -def test_delete_bumps_visibility_epoch_not_stale_in_memory(): +def test_delete_bumps_epoch_not_stale(): t = _make_table(20) t.create_index("id") t.delete(0) @@ -226,7 +226,7 @@ def test_compact_index_in_memory(): @pytest.mark.heavy -def test_multi_column_conjunction_uses_multiple_indexes_in_memory(): +def test_conjunction_uses_multiple_indexes(): t = _make_table(200) t.create_index("id", kind=blosc2.IndexKind.FULL) t.create_index("category", kind=blosc2.IndexKind.FULL) @@ -240,7 +240,7 @@ def test_multi_column_conjunction_uses_multiple_indexes_in_memory(): assert ids_idx == ids_scan -def test_full_index_large_ctable_column_matches_scan_in_memory(): +def test_full_index_large_column_matches_scan(): @dataclasses.dataclass class SensorRow: sensor_id: int = blosc2.field(blosc2.int32()) @@ -288,7 +288,7 @@ def test_create_index_persistent(tmpdir): assert sidecars, "No sidecar .b2nd files found" -def test_create_index_persistent_does_not_cache_sidecar_handles(tmpdir): +def test_create_index_does_not_cache_sidecars(tmpdir): import blosc2.indexing as indexing path = str(tmpdir / "table.b2d") @@ -303,7 +303,7 @@ def test_create_index_persistent_does_not_cache_sidecar_handles(tmpdir): assert cached == [] -def test_persistent_ctable_releases_immediately_without_gc(tmpdir): +def test_persistent_releases_without_gc(tmpdir): path = str(tmpdir / "table.b2d") def build_table(): @@ -372,7 +372,7 @@ def test_where_with_index_matches_scan_persistent(tmpdir): @pytest.mark.heavy -def test_relative_b2d_ctable_index_sidecars_survive_reopen(tmp_path, monkeypatch): +def test_relative_b2d_sidecars_survive_reopen(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) t = _make_table(200, persistent_path="table.b2d") t.create_index("id", kind=blosc2.IndexKind.BUCKET) @@ -385,7 +385,7 @@ def test_relative_b2d_ctable_index_sidecars_survive_reopen(tmp_path, monkeypatch @pytest.mark.heavy -def test_persistent_index_drop_releases_sidecars_without_gc(tmpdir): +def test_index_drop_releases_sidecars_no_gc(tmpdir): import gc def run_query_and_drop(): @@ -434,7 +434,7 @@ def test_expression_index_persistent_roundtrip(tmpdir): assert len(result) > 0 -def test_sort_by_computed_column_with_expression_full_index(): +def test_sort_by_computed_col_full_index(): t = _make_table(40) t.add_computed_column("score", "value * category") t.create_index(expression="value * category", kind=blosc2.IndexKind.FULL, name="score_expr") @@ -462,7 +462,7 @@ def test_drop_index_persistent_catalog_cleared(tmpdir): assert len(t2.indexes) == 0 -def test_drop_indexed_column_removes_persistent_sidecars(tmpdir): +def test_drop_indexed_col_removes_sidecars(tmpdir): path = str(tmpdir / "table.b2d") t = _make_table(30, persistent_path=path) t.create_index("id") @@ -539,7 +539,7 @@ def test_query_after_reopen_persistent(tmpdir): assert ids == list(range(91, 100)) -def test_rename_indexed_column_rebuilds_catalog_persistent(tmpdir): +def test_rename_indexed_col_rebuilds_catalog(tmpdir): path = str(tmpdir / "table.b2d") t = _make_table(40, persistent_path=path) t.create_index("id") @@ -629,7 +629,7 @@ def test_indexes_multiple_columns(): assert col_names == {"id", "category"} -def test_indexed_ctable_b2z_double_open_append_no_corruption(tmp_path): +def test_b2z_double_open_append_no_corruption(tmp_path): """Opening an indexed CTable .b2z in append mode twice must not corrupt it. Regression test: GC of a CTable opened from .b2z was calling close() → @@ -688,7 +688,8 @@ def test_indexing_purges_stale_persistent_caches(): assert all(tmpdir not in path for path in indexing._GATHER_MMAP_HANDLES) -def test_indexing_purge_tolerates_reentrant_sidecar_handle_cache_mutation(monkeypatch): +def test_purge_tolerates_reentrant_cache_change(monkeypatch): + """Purging survives a sidecar handle cache mutated re-entrantly mid-purge.""" import blosc2.indexing as indexing stale_scope = ("persistent", "/tmp/stale-index.b2nd") @@ -713,7 +714,7 @@ def mutating_exists(path): indexing._SIDECAR_HANDLE_CACHE.pop(injected_key, None) -def test_summary_index_compact_store_no_cross_column_confusion(tmp_path): +def test_summary_compact_no_cross_column_mixup(tmp_path): """Regression: a SUMMARY index on one column of a compact (.b2z) store must not be applied to a *different* column's predicate. @@ -756,7 +757,7 @@ class Aligned: assert got == expected, f"index returned {got}, expected {expected} (scan)" -def test_sidecar_handle_cache_no_cross_column_collision(tmp_path): +def test_sidecar_cache_no_cross_col_collision(tmp_path): """Regression: in a compact (.b2z) multi-column store, reading the SUMMARY block sidecar handle for each column must return *that* column's data, not a sibling's. @@ -900,7 +901,7 @@ def test_incremental_summary_matches_ooc_build(tmp_path): assert np.allclose(a["max"], b["max"], equal_nan=True) -def test_incremental_summary_invalidated_by_inplace_update(tmp_path): +def test_incremental_summary_stale_on_inplace(tmp_path): """An in-place column write before close must invalidate the accumulator so the builder falls back to a correct full rescan.""" f, i = _build_incr_data(n=4000) @@ -934,7 +935,7 @@ def test_granularity_only_valid_for_summary(): @pytest.mark.heavy @pytest.mark.parametrize("threshold", [5.0, 50.0, 99.0, 99.99]) -def test_summary_cost_gate_correctness_across_selectivity(threshold): +def test_summary_cost_gate_across_selectivity(threshold): """The SUMMARY cost gate may use the index (selective query) or fall back to a scan (broad query); both branches must return scan-correct results.""" t, _ = _make_gran_table(n=6000) @@ -1103,7 +1104,7 @@ def test_cross_column_or_prunes_segments_compact_b2z(tmp_path, monkeypatch): assert pruned, "cross-column OR fell back to a full scan instead of pruning" -def test_cross_column_predicates_match_scan_compact_b2z(tmp_path): +def test_cross_column_preds_match_scan_b2z(tmp_path): """Cross-column AND/OR over two SUMMARY-indexed columns must match the boolean-mask (no-index) result across selective, non-selective, empty, and mixed-direction predicates.""" @@ -1141,7 +1142,7 @@ def _seg_plan(units, *, base_nrows=1000, segment_len=250, level="block"): ) -def test_merge_segment_plans_intersection_union_and_fallback(): +def test_merge_segment_plans_and_fallback(): """Unit-level guard for the cross-column merge semantics.""" from blosc2.indexing import _merge_segment_plans @@ -1233,7 +1234,7 @@ def test_coalesce_spans_merges_within_a_block(): assert merged == [(0, 402)] -def test_bucket_block_fraction_counts_blocks_not_buckets(): +def test_bucket_gate_counts_blocks_not_buckets(): """Selectivity in buckets overstates the saving; the read unit is the block.""" frac = blosc2.indexing._bucket_block_fraction geom = {"nav_segment_len": 16384, "bucket_len": 256} # 64 buckets per block diff --git a/tests/ctable/test_ctable_ndarray_columns.py b/tests/ctable/test_ctable_ndarray_columns.py index bb7da1d1c..f3281ccff 100644 --- a/tests/ctable/test_ctable_ndarray_columns.py +++ b/tests/ctable/test_ctable_ndarray_columns.py @@ -40,7 +40,7 @@ def test_ndarray_column_metadata_and_tuple_indexing(): np.testing.assert_array_equal(t.image[:, :, :, 0], np.stack([np.ones((2, 2)), np.full((2, 2), 2)])) -def test_ndarray_column_comparison_and_scalar_operation_guards(): +def test_ndarray_col_comparison_scalar_guards(): t = table() with pytest.raises(TypeError, match="Cannot compare ndarray column 'embedding' directly"): @@ -55,7 +55,7 @@ def test_ndarray_column_comparison_and_scalar_operation_guards(): t.create_index("embedding") -def test_ndarray_column_axis_reductions_and_where_projection(): +def test_ndarray_col_axis_reductions_and_where(): t = table() assert t.embedding.sum() == np.float32(21) @@ -71,7 +71,8 @@ def test_ndarray_column_axis_reductions_and_where_projection(): np.testing.assert_array_equal(filtered.id[:], np.array([2], dtype=np.int32)) -def test_generated_column_row_transformer_append_refresh_and_vector_output(): +def test_generated_col_transformer_lifecycle(): + """Append, refresh, and a vector-returning transformer, in one lifecycle.""" t = table() t.add_generated_column( @@ -105,7 +106,7 @@ def test_generated_column_row_transformer_append_refresh_and_vector_output(): np.testing.assert_allclose(t.image_mean_rgb[:], t.image[:].mean(axis=(1, 2))) -def test_stale_generated_column_raises_and_read_stale_escape_hatch(): +def test_stale_generated_col_read_stale_hatch(): t = table() t.add_generated_column( "embedding_sum", @@ -141,7 +142,7 @@ class NullableNDArrayRow: codes: object = blosc2.field(blosc2.ndarray((2,), dtype=blosc2.int16(), nullable=True)) -def test_nullable_ndarray_columns_append_extend_assign_and_reduce(): +def test_nullable_ndarray_cols_write_and_reduce(): t = blosc2.CTable(NullableNDArrayRow) t.append((1, np.array([1, 2, 3], dtype=np.float32), [4, 5])) @@ -231,7 +232,7 @@ def test_nullable_ndarray_arrow_roundtrip(): np.testing.assert_array_equal(rt.codes.is_null(), t.codes.is_null()) -def test_ndarray_column_setitem_blosc2_ndarray_no_holes(): +def test_ndarray_col_setitem_b2_no_holes(): """col[:] = blosc2.NDArray fast path works for fixed-shape ndarray columns.""" n = 50 diff --git a/tests/ctable/test_ctable_take.py b/tests/ctable/test_ctable_take.py index 3d36d81d3..0b9281b49 100644 --- a/tests/ctable/test_ctable_take.py +++ b/tests/ctable/test_ctable_take.py @@ -36,7 +36,7 @@ def make_table(n=8): return t -def test_ctable_take_preserves_order_duplicates_and_negative_indices(): +def test_take_keeps_order_dups_and_negatives(): t = make_table(8) t.delete(2) t.delete(5) @@ -83,7 +83,7 @@ def test_ctable_take_handles_varlen_and_list_columns(): assert list(result["tags"][:]) == [[2, 20], [0], [2, 20], None] -def test_column_take_preserves_order_duplicates_and_negative_indices(): +def test_col_take_keeps_order_dups_and_negs(): t = make_table(8) t.delete(2) t.delete(5) @@ -152,7 +152,7 @@ def test_column_take_rejects_bad_indices(): col.take([4]) -def test_top_level_take_rejects_axis_for_ctable_and_column(): +def test_top_level_take_rejects_axis(): t = make_table(4) with pytest.raises(ValueError, match="axis"): @@ -193,7 +193,7 @@ def test_slice_copy_false_is_a_zero_copy_view(): np.testing.assert_array_equal(view["id"][:], np.arange(2, 6, dtype=np.int32)) -def test_slice_copy_true_is_an_independent_compact_table(): +def test_slice_copy_true_is_independent(): t = make_table(8) sub = t.slice(2, 6) # copy=True by default assert sub._cols is not t._cols diff --git a/tests/ctable/test_dictionary_column.py b/tests/ctable/test_dictionary_column.py index 757744619..a4cfead2f 100644 --- a/tests/ctable/test_dictionary_column.py +++ b/tests/ctable/test_dictionary_column.py @@ -215,7 +215,7 @@ class Row: assert ct.where('"Acme" in company and amount > 8')["amount"][:].tolist() == [9.0] assert ct.where('"Acme" in company or "Beta" in company').nrows == 3 - def test_string_where_dictionary_literal_with_special_chars(self): + def test_where_dict_literal_special_chars(self): # Literals with commas/spaces/dashes (e.g. chicago-taxi company names). @dataclass class Row: @@ -229,7 +229,7 @@ class Row: assert ct.where(f'company == "{name}"')["n"][:].tolist() == [1, 3] assert ct.where(f"company == '{name}'")["n"][:].tolist() == [1, 3] # single quotes too - def test_dictionary_predicate_combines_with_regular_predicate_in_aggregate(self): + def test_dict_pred_combines_in_aggregate(self): ct = CTable(TripRow) ct.extend(DATA_TUPLES) assert ct["fare"].sum(where=(ct["fare"] > 6) & (ct["vendor"] == "Uber")) == pytest.approx(25.5) @@ -659,7 +659,7 @@ class Row: assert results["scan"]["apple"][0] == ["apple", "apple"] -def test_dict_rank_index_staleness_uses_the_value_epoch(tmp_path): +def test_dict_rank_staleness_uses_value_epoch(tmp_path): """The staleness check must not re-hash the whole dictionary per query.""" from blosc2.ctable_indexing import _dict_rank_hash diff --git a/tests/ctable/test_getitem_access.py b/tests/ctable/test_getitem_access.py index 85b664dd2..6ba76965e 100644 --- a/tests/ctable/test_getitem_access.py +++ b/tests/ctable/test_getitem_access.py @@ -32,7 +32,7 @@ class AccessRow: ] -def test_display_rows_printoption_truncates_to_five_head_and_tail_rows(): +def test_display_rows_truncates_head_and_tail(): previous = blosc2.get_printoptions() try: t = CTable(AccessRow, new_data=[(i, float(i), True, str(i), [i]) for i in range(60)]) @@ -66,7 +66,7 @@ def test_display_rows_printoption_truncates_to_five_head_and_tail_rows(): ) -def test_rename_column_recomputes_display_width_for_shorter_name(): +def test_rename_col_recomputes_display_width(): @dataclass class WidthRow: very_long_temporary_name: float = blosc2.field(blosc2.float64()) @@ -80,7 +80,7 @@ class WidthRow: assert t._col_widths["x"] == max(len("x"), t._schema.columns_by_name["x"].display_width) -def test_display_precision_printoption_formats_float_values(): +def test_display_precision_formats_floats(): previous = blosc2.get_printoptions() try: t = CTable(AccessRow, new_data=[(1, 1.23456789, True, "x", [1])]) @@ -232,7 +232,7 @@ def test_getitem_slice_returns_view(): assert sub.base is t -def test_getitem_integer_list_and_bool_mask_return_views(): +def test_getitem_int_list_and_bool_mask_views(): t = CTable(AccessRow, new_data=DATA) gathered = t[[3, 0, 2]] assert isinstance(gathered, CTable) @@ -288,7 +288,7 @@ def test_getitem_non_boolean_expression_raises(): _ = t["id + 1"] -def test_ctable_array_materialization_uses_structured_dtype(): +def test_array_materialization_structured(): t = CTable(AccessRow, new_data=DATA) arr = np.asarray(t) assert arr.dtype.fields is not None diff --git a/tests/ctable/test_groupby.py b/tests/ctable/test_groupby.py index 3830d7efb..9d1a76620 100644 --- a/tests/ctable/test_groupby.py +++ b/tests/ctable/test_groupby.py @@ -76,7 +76,7 @@ def test_groupby_agg_numeric_reductions(): assert got[2] == ("Rome", 60.0, 30.0, 20.0, 40.0, 2) -def test_groupby_argmin_argmax_return_logical_positions(): +def test_groupby_argmin_argmax_logical_pos(): t = CTable(SalesRow, new_data=DATA) out = t.group_by("city", sort=True).agg({"sales": ["argmin", "argmax"]}) @@ -85,7 +85,7 @@ def test_groupby_argmin_argmax_return_logical_positions(): assert rows(out) == [("Berlin", -1, -1), ("Paris", 0, 3), ("Rome", 2, 4)] -def test_groupby_argmin_argmax_convenience_methods_and_view_positions(): +def test_groupby_argmin_methods_and_view_pos(): t = CTable(SalesRow, new_data=DATA) view = t.where("qty >= 3") @@ -161,7 +161,7 @@ class DictRow: sales: int = blosc2.field(blosc2.int32()) -def test_groupby_dictionary_key_groups_by_decoded_value(): +def test_groupby_dict_key_groups_by_value(): t = CTable(DictRow, new_data=[("Paris", 10), ("Rome", 20), ("Paris", 30)]) out = t.group_by("city", sort=True).agg({"sales": "sum"}) @@ -170,7 +170,7 @@ def test_groupby_dictionary_key_groups_by_decoded_value(): assert rows(out) == [("Paris", 40), ("Rome", 20)] -def test_groupby_dictionary_key_sorted_by_string_not_code_order(): +def test_groupby_dict_key_sorted_by_string(): """Dict groups come out alphabetical even when codes are assigned otherwise. Regression for the always-sorted contract: with "Rome" seen before "Paris" @@ -188,7 +188,7 @@ def test_groupby_dictionary_key_sorted_by_string_not_code_order(): assert rows(out) == [("Paris", 1, 3), ("Rome", 2, 0)] -def test_groupby_dictionary_key_sorted_matches_python_sorted(): +def test_groupby_dict_key_matches_python_sort(): """Vectorized dict-key ordering matches a Python sorted() reference.""" rng = np.random.default_rng(0) labels = [f"city_{i:03d}" for i in range(200)] @@ -206,7 +206,7 @@ def test_groupby_string_key_sorted_without_sort_flag(): assert [r[0] for r in rows(out)] == ["Berlin", "Paris", "Rome"] -def test_groupby_dictionary_key_argmin_argmax_positions(): +def test_groupby_dict_key_argmin_argmax_pos(): # Dictionary key drives the dense-position fast path; verify it returns the # logical row positions of the extremes (chicago-taxi "company" shape). t = CTable(DictRow, new_data=[("Paris", 10), ("Rome", 50), ("Paris", 30), ("Rome", 20)]) @@ -218,7 +218,7 @@ def test_groupby_dictionary_key_argmin_argmax_positions(): assert rows(out) == [("Paris", 0, 2), ("Rome", 3, 1)] -def test_groupby_dictionary_key_beyond_default_code_capacity(): +def test_groupby_dict_key_beyond_capacity(): data = [("Paris" if i % 2 == 0 else "Rome", 1) for i in range(5000)] t = CTable(DictRow, new_data=data) @@ -313,7 +313,7 @@ def test_groupby_fast_path_sum_variants(row_type, data, expected): assert rows(out) == expected -def test_groupby_float_integral_fast_path_falls_back_for_non_integral_keys(): +def test_groupby_float_path_falls_back_fractional(): t = CTable(Float64KeyRow, new_data=[(0.5, 1.0), (1.5, 2.0), (0.5, 3.0)]) # Float keys are not key-sorted by default (sort=None); request sort=True to @@ -323,7 +323,7 @@ def test_groupby_float_integral_fast_path_falls_back_for_non_integral_keys(): assert rows(out) == [(0.5, 4.0), (1.5, 2.0)] -def test_groupby_float_integral_fast_path_falls_back_for_nan_group_when_kept(): +def test_groupby_float_path_falls_back_nan_group(): t = CTable(Float64KeyRow, new_data=[(0.0, 1.0), (np.nan, 2.0), (0.0, 3.0)]) out = t.group_by("key", dropna=False).agg({"value": "sum"}) @@ -349,7 +349,7 @@ def test_groupby_integral_float_key_dense_min_max(row_type): assert out_max._cols["key"][:].dtype == t._cols["key"][:].dtype -def test_groupby_integral_float_key_falls_back_for_negative_keys(): +def test_groupby_float_key_falls_back_negative(): # Negative keys cannot use the dense (non-negative) mapping; the generic # path must still produce correct max results. t = CTable(Float64KeyRow, new_data=[(-1.0, 5.0), (-1.0, 8.0), (2.0, 3.0)]) @@ -368,7 +368,7 @@ def test_group_reduce_object_keys_sort_with_none(): assert sizes.tolist() == [1, 1, 2] -def test_group_reduce_object_numeric_keys_sort_with_none(): +def test_group_reduce_numeric_keys_with_none(): groups, sizes = blosc2.group_reduce(np.array([None, 2, 1, 2], dtype=object), sort=True, dropna=False) assert groups.tolist() == [None, 1, 2] @@ -474,7 +474,7 @@ def test_groupby_cython_integer_key_more_integer_aggs(): assert rows(out) == [(0, 2, 2, 3, 1.5, -2, 5), (1, 2, 2, 30, 15.0, 10, 20), (2, 1, 1, 7, 7.0, 7, 7)] -def test_groupby_cython_integer_key_nullable_float_aggs(): +def test_groupby_cython_int_key_null_aggs(): row_type = make_dataclass( "IntKeyNullableFloatAggsRow", [ @@ -514,7 +514,7 @@ def test_groupby_cython_arbitrary_float_key_aggs(): ] -def test_groupby_cython_arbitrary_float_key_nan_and_signed_zero(): +def test_groupby_cython_float_key_nan_and_zero(): t = CTable(Float64KeyRow, new_data=[(-0.0, 1.0), (0.0, 2.0), (np.nan, 3.0), (np.nan, 4.0)]) dropped = t.group_by("key").agg({"value": "sum"}) @@ -595,7 +595,7 @@ def test_groupby_persistent_output_urlpath(tmp_path): assert rows(reopened) == [("Berlin", 6), ("Paris", 7), ("Rome", 8)] -def test_groupby_persistent_output_urlpath_on_convenience_method(tmp_path): +def test_groupby_persistent_urlpath_shorthand(tmp_path): t = CTable(SalesRow, new_data=DATA) path = tmp_path / "grouped_mean.b2d" @@ -621,7 +621,7 @@ def _keys(out): return [out._cols[out.col_names[0]][i] for i in range(out.nrows)] -def test_groupby_int_key_always_ascending_regardless_of_sort(): +def test_groupby_int_key_always_ascending(): # Integer/dense keys come out ascending under every sort= value -- nonzero # ordering is free and unavoidable. t = CTable(Int32FloatRow, new_data=_INT_SORT_DATA) @@ -638,7 +638,7 @@ def test_groupby_dict_key_sorted_under_auto_and_true(): assert _keys(t.group_by("key", sort=False).sum("value")) == ["zeta", "alpha", "mike"] -def test_groupby_float_key_unsorted_under_auto_sorted_under_true(): +def test_groupby_float_key_auto_vs_sorted(): # Float keys only sort via a Python list.sort, so None (auto) leaves them # unsorted; True sorts. The unsorted order must be deterministic across runs. t = CTable(Float64KeyRow, new_data=_FLOAT_SORT_DATA) @@ -649,7 +649,7 @@ def test_groupby_float_key_unsorted_under_auto_sorted_under_true(): assert sorted(auto1) == [1.5, 2.5, 3.5] # same groups, order unspecified -def test_groupby_multikey_unsorted_under_auto_sorted_under_true(): +def test_groupby_multikey_auto_vs_sorted(): # Multi-key results only sort via a Python list.sort, so None (auto) leaves # them unsorted (deterministic but unspecified order); True sorts. data = [("z", 2, 1.0), ("a", 1, 2.0), ("z", 1, 3.0), ("a", 1, 4.0)] @@ -915,7 +915,7 @@ def inconsistent(values): g.agg(x=("sales", inconsistent)) -def test_agg_udf_unsupported_result_dtype_raises_clear_error(): +def test_agg_udf_bad_result_dtype_raises(): t = CTable(SalesRow, new_data=DATA) g = t.group_by("city") @@ -1065,7 +1065,7 @@ def test_factorize_fixed_width_str_matches_np_unique(): np.testing.assert_array_equal(got_inv, ref_inv) -def test_factorize_fixed_width_str_collision_falls_back(monkeypatch): +def test_factorize_str_collision_falls_back(monkeypatch): """With the mix constant forced to 0, the row hash degenerates to the last uint32 word, so strings differing only in earlier characters collide -- the verify pass must detect it and fall back to exact np.unique.""" diff --git a/tests/ctable/test_nested_access_storage.py b/tests/ctable/test_nested_access_storage.py index 485d305a6..5b35b2486 100644 --- a/tests/ctable/test_nested_access_storage.py +++ b/tests/ctable/test_nested_access_storage.py @@ -25,7 +25,7 @@ class PersistRow: a: int -def test_dotted_column_attribute_namespace_and_where_string(): +def test_dotted_col_attribute_and_where_string(): t = blosc2.CTable(AccessRow) t.append((1.0, 10.0)) t.append((2.0, 30.0)) @@ -44,7 +44,7 @@ def test_dotted_column_attribute_namespace_and_where_string(): assert view2.nrows == 2 -def test_dotted_column_persists_under_hierarchical_cols(tmp_path): +def test_dotted_col_persists_hierarchical(tmp_path): t = blosc2.CTable(PersistRow) t.append((1,)) t.rename_column("a", "trip.begin.lon") @@ -69,7 +69,7 @@ def test_select_struct_prefix_expands_descendants(): assert s.col_names == ["trip.begin.lon"] -def test_from_arrow_flattens_struct_columns_to_dotted_leaves(): +def test_from_arrow_flattens_struct_to_dotted(): trip_type = pa.struct([("begin", pa.struct([("lon", pa.float64()), ("lat", pa.float64())]))]) schema = pa.schema([pa.field("trip", trip_type)]) batch = pa.record_batch( @@ -107,7 +107,7 @@ def test_from_arrow_flattens_struct_columns_to_dotted_leaves(): row0["nope"] -def test_nested_field_name_escaping_for_literal_dot_and_slash(tmp_path): +def test_field_name_escaping_dot_and_slash(tmp_path): trip_type = pa.struct([pa.field("begin/point", pa.struct([pa.field("lon.deg", pa.float64())]))]) schema = pa.schema([pa.field("trip.info", trip_type)]) batch = pa.record_batch( diff --git a/tests/ctable/test_nested_metadata_root.py b/tests/ctable/test_nested_metadata_root.py index 5a6989df1..5b82794e2 100644 --- a/tests/ctable/test_nested_metadata_root.py +++ b/tests/ctable/test_nested_metadata_root.py @@ -18,7 +18,7 @@ def _table_with_empty_root_alias(): return blosc2.CTable.from_arrow(schema, [batch]) -def test_schema_version_2_with_nested_metadata_roundtrip(): +def test_schema_v2_nested_metadata_roundtrip(): schema = pa.schema([pa.field("x.y", pa.float64())]) batch = pa.record_batch([pa.array([1.0, 2.0])], schema=schema) t = blosc2.CTable.from_arrow(schema, [batch]) @@ -31,13 +31,13 @@ def test_schema_version_2_with_nested_metadata_roundtrip(): assert restored.metadata["nested"]["logical_to_physical"]["x.y"] == "x.y" -def test_empty_root_metadata_exports_back_to_empty_arrow_name(): +def test_empty_root_exports_empty_arrow_name(): t = _table_with_empty_root_alias() out = t.to_arrow() assert out.schema.names == [""] -def test_empty_root_logical_alias_getitem_select_and_index(): +def test_empty_root_alias_getitem_and_select(): t = _table_with_empty_root_alias() assert t[""][0] == 1.0 s = t.select([""]) diff --git a/tests/ctable/test_null_expressions.py b/tests/ctable/test_null_expressions.py index c17dc2400..a97ce22a7 100644 --- a/tests/ctable/test_null_expressions.py +++ b/tests/ctable/test_null_expressions.py @@ -64,7 +64,7 @@ def test_eq_sentinel_literal_does_not_match_null(): assert t[t.score == NULL_I64]["id"][:].tolist() == [] -def test_ne_sentinel_literal_does_not_match_null_either(): +def test_ne_sentinel_literal_no_null_match(): t = CTable(IntRow, new_data=[(1, 10, 0), (2, NULL_I64, 0)]) # A null never satisfies `!=` either — it isn't "not equal", it's unknown. assert t[t.score != NULL_I64]["id"][:].tolist() == [1] @@ -75,7 +75,7 @@ def test_is_null_still_finds_nulls(): assert list(t.score.is_null()) == [False, True] -def test_comparison_between_two_nullable_columns_excludes_either_null(): +def test_two_nullable_cols_exclude_either_null(): t = CTable( IntRow, new_data=[ @@ -94,7 +94,7 @@ def test_ge_le_also_exclude_nulls(): assert t[t.score <= -20]["id"][:].tolist() == [3] -def test_comparison_against_nan_scalar_does_not_crash_and_matches_nothing(): +def test_nan_scalar_comparison_matches_nothing(): """Regression: ``t.f == np.nan`` used to crash with NameError inside the lazyexpr evaluator (the scalar was embedded as the bare literal ``nan``). Now it evaluates -- and matches nothing, since a null satisfies no @@ -236,7 +236,7 @@ def test_reduction_on_derived_expression_skips_nulls(): assert (t.score + 1).std() == pytest.approx(15.0) -def test_reduction_on_chained_and_mixed_expressions_skips_nulls(): +def test_chained_expr_reduction_skips_nulls(): t = CTable(IntRow, new_data=[(1, 10, 5), (2, NULL_I64, 5), (3, -20, NULL_I64)]) assert ((t.score + 1) * 2).sum() == pytest.approx(2 * (11 - 19)) # nullable + nullable: null wherever either operand is null -> only row 1 live @@ -248,7 +248,7 @@ def test_reduction_on_chained_and_mixed_expressions_skips_nulls(): assert (t.score**0).sum() == pytest.approx(2.0) # nan**0 must not resurrect the null -def test_reduction_on_derived_expression_matches_pandas(): +def test_derived_expr_reduction_vs_pandas(): pd = pytest.importorskip("pandas") t = CTable(IntRow, new_data=[(1, 10, 0), (2, NULL_I64, 0), (3, -20, 0), (4, 7, 0)]) s = pd.Series([10, None, -20, 7], dtype="Int64") @@ -256,7 +256,7 @@ def test_reduction_on_derived_expression_matches_pandas(): assert (t.score + 1).mean() == pytest.approx(float((s + 1).mean())) -def test_derived_expression_reductions_respect_deleted_rows_and_views(): +def test_derived_expr_respects_deletes_and_views(): t = CTable(IntRow, new_data=[(1, 10, 0), (2, NULL_I64, 0), (3, -20, 0), (4, 7, 0)]) t.delete([0]) # drop score=10 assert (t.score + 1).sum() == pytest.approx(-19 + 8) @@ -264,7 +264,7 @@ def test_derived_expression_reductions_respect_deleted_rows_and_views(): assert (view.score + 1).sum() == pytest.approx(8.0) -def test_derived_expression_all_null_reduction_semantics(): +def test_derived_expr_all_null_reduction(): t = CTable(IntRow, new_data=[(1, NULL_I64, 0), (2, NULL_I64, 0)]) assert (t.score + 1).sum() == 0.0 # same convention as Column.sum() assert math.isnan((t.score + 1).mean()) @@ -274,7 +274,7 @@ def test_derived_expression_all_null_reduction_semantics(): (t.score + 1).max() -def test_derived_expression_ne_comparison_excludes_nulls(): +def test_derived_expr_ne_excludes_nulls(): t = CTable(IntRow, new_data=[(1, 10, 0), (2, NULL_I64, 0), (3, -20, 0)]) assert t[(t.score + 1) != 11]["id"][:].tolist() == [3] assert t[(t.score + 1) > 0]["id"][:].tolist() == [1] diff --git a/tests/ctable/test_nullable.py b/tests/ctable/test_nullable.py index ee5cb807d..779fe34c3 100644 --- a/tests/ctable/test_nullable.py +++ b/tests/ctable/test_nullable.py @@ -78,7 +78,7 @@ def test_null_value_property_set(): assert t["score"].null_value == -1 -def test_numpy_nan_null_value_skips_scalar_validation_constraints(): +def test_nan_null_value_skips_validation(): @dataclass class NumpyNaNFloatRow: value: float = blosc2.field(blosc2.float32(ge=0, null_value=np.float32(np.nan))) @@ -127,7 +127,7 @@ class Row: assert t["b"].dtype.itemsize >= len(b"__BLOSC2_NULL__") -def test_nullable_true_uses_null_policy_context_and_column_null_values(): +def test_nullable_uses_policy_and_col_nulls(): @dataclass class Row: i: int = blosc2.field(blosc2.int32(nullable=True)) @@ -163,7 +163,7 @@ def test_add_column_nullable_true_uses_null_policy(): assert t["extra"].null_value == np.iinfo(np.int32).max -def test_nullable_policy_rejects_out_of_range_integer_sentinel(): +def test_policy_rejects_out_of_range_sentinel(): @dataclass class Row: x: int = blosc2.field(blosc2.int8(nullable=True)) @@ -173,7 +173,7 @@ class Row: CTable(Row) -def test_nullable_policy_rejects_wrong_string_sentinel_type(): +def test_policy_rejects_wrong_sentinel_type(): @dataclass class Row: s: str = blosc2.field(blosc2.string(nullable=True)) @@ -636,7 +636,7 @@ def test_all_nulls_value_counts_empty(): assert len(vc) == 0 -def test_null_value_does_not_affect_non_nullable_column(): +def test_null_value_ignored_if_not_nullable(): t = CTable(IntRow, new_data=[(1, 10), (2, 20)]) # id column has no null_value — aggregates work normally assert t["id"].sum() == 3 diff --git a/tests/ctable/test_object_spec.py b/tests/ctable/test_object_spec.py index 9b6154dc6..0a072a41f 100644 --- a/tests/ctable/test_object_spec.py +++ b/tests/ctable/test_object_spec.py @@ -59,7 +59,7 @@ class StrictObjectRow: t.append([None]) -def test_object_column_rejects_non_msgpack_value_on_flush(): +def test_object_col_rejects_non_msgpack(): t = CTable(ObjectRow) t.append([1, {"not-msgpack": {1, 2, 3}}]) with pytest.raises(TypeError): diff --git a/tests/ctable/test_parquet_interop.py b/tests/ctable/test_parquet_interop.py index 16676c74a..908becedb 100644 --- a/tests/ctable/test_parquet_interop.py +++ b/tests/ctable/test_parquet_interop.py @@ -267,7 +267,7 @@ class StructRow: reopened = CTable.open(str(path), mode="r") assert reopened["props"][:] == [{"a": 1, "b": "x"}, None, {"a": 2, "b": "yy"}] - def test_from_arrow_object_fallback_for_unsupported_type(self): + def test_from_arrow_object_fallback(self): map_type = pa.map_(pa.string(), pa.int32()) batch = pa.record_batch( [pa.array([[("a", 1)], None, [("b", 2), ("c", 3)]], type=map_type)], names=["attrs"] @@ -408,7 +408,7 @@ def test_from_arrow_blosc2_batch_size_default(self): assert t["vals"][0] == [1] assert t["vals"][1] == [2, 3] - def test_from_arrow_blosc2_batch_size_override_and_none(self): + def test_from_arrow_batch_size_override(self): at = pa.table({"vals": pa.array([[1], [2], [3]], type=pa.list_(pa.int64()))}) t = CTable.from_arrow(at.schema, at.to_batches(max_chunksize=1), blosc2_batch_size=2) assert t._schema.columns_by_name["vals"].spec.batch_rows == 2 @@ -576,7 +576,7 @@ def test_null_policy_controls_default_sentinels(self): assert t._schema.columns_by_name["i"].spec.null_value == np.iinfo(np.int32).max assert t["i"].null_count() == 1 - def test_null_policy_string_value_applies_to_fixed_width_strings(self): + def test_null_policy_applies_to_fixed_width(self): """string_value in NullPolicy applies when string_max_length is given explicitly.""" at = pa.table( { @@ -613,14 +613,14 @@ def test_null_values_override_policy_and_auto_false(self, tmp_path): t2 = CTable.from_parquet(path, auto_null_sentinels=False) assert t2._schema.columns_by_name["i"].spec.null_value == -1 - def test_null_policy_rejects_vlbytes_column_null_values(self): + def test_null_policy_rejects_vlbytes_nulls(self): """Passing column_null_values for a vlbytes column raises TypeError.""" at = pa.table({"b": pa.array([b"a", None, b"c"], type=pa.large_binary())}) policy = blosc2.NullPolicy(column_null_values={"b": b"NA"}) with blosc2.null_policy(policy), pytest.raises(TypeError, match="vlbytes"): CTable.from_arrow(at.schema, at.to_batches()) - def test_null_policy_column_null_values_applies_to_utf8(self): + def test_null_policy_col_nulls_apply_to_utf8(self): """Passing column_null_values for a utf8 (scalar string) column sets its sentinel. On NumPy < 2.0 utf8 columns are unavailable, strings import as @@ -722,7 +722,7 @@ def test_max_rows_from_parquet_limits_rows(self, tmp_path): assert len(out) == 6 np.testing.assert_array_equal(out["id"][:], np.arange(6)) - def test_max_rows_zero_from_parquet_imports_empty_table(self, tmp_path): + def test_max_rows_zero_imports_empty_table(self, tmp_path): t = CTable(Row, new_data=DATA10) path = tmp_path / "x.parquet" t.to_parquet(path) @@ -784,7 +784,7 @@ def test_parquet_cli_nested_progress_skips_write_lines(tmp_path, capsys): assert " write" not in captured.out -def test_parquet_cli_separate_nested_flattens_top_level_structs(tmp_path, capsys): +def test_cli_separate_nested_flattens_structs(tmp_path, capsys): from blosc2.cli.parquet_to_blosc2 import main trip_type = pa.struct( @@ -819,7 +819,8 @@ def test_parquet_cli_separate_nested_flattens_top_level_structs(tmp_path, capsys ct.close() -def test_parquet_cli_no_separate_nested_preserves_top_level_struct_as_list(tmp_path): +def test_cli_no_separate_nested_keeps_struct(tmp_path): + """Without --separate-nested a top-level struct stays one list column.""" from blosc2.cli.parquet_to_blosc2 import main trip_type = pa.struct([pa.field("sec", pa.float32())]) diff --git a/tests/ctable/test_schema_mutations.py b/tests/ctable/test_schema_mutations.py index 9213a42cd..698941d0f 100644 --- a/tests/ctable/test_schema_mutations.py +++ b/tests/ctable/test_schema_mutations.py @@ -114,7 +114,7 @@ def test_view_blocks_assign(): assert t["score"][5] == pytest.approx(50.0) -def test_take_from_view_yields_independent_writable_table(): +def test_take_from_view_is_independent(): t = CTable(Row, new_data=DATA10) view = t.where(t["id"] > 4) independent = view.take([0, 1]) @@ -190,7 +190,7 @@ def test_blosc2_open_raw_treestore_without_manifest(): assert np.array_equal(opened["/group/node"][:], np.arange(5)) -def test_blosc2_open_raw_treestore_for_unknown_manifest_kind(): +def test_open_raw_treestore_unknown_manifest(): path = table_path("unknown_manifest") with blosc2.TreeStore(path, mode="w", threshold=0) as tstore: meta = blosc2.SChunk() @@ -204,7 +204,7 @@ def test_blosc2_open_raw_treestore_for_unknown_manifest_kind(): assert np.array_equal(opened["/payload"][:], np.arange(3)) -def test_extensionless_ctable_path_uses_extensionless_store(): +def test_extensionless_path_uses_that_store(): path = os.path.join(TABLE_ROOT, "alias_ctable") t = CTable(Row, urlpath=path, mode="w", new_data=DATA10) t.close() @@ -271,14 +271,14 @@ def test_add_column_fills_default_for_existing_rows(): np.testing.assert_array_equal(t["weight"][:], np.full(10, 5.5)) -def test_add_column_without_default_allowed_for_empty_table(): +def test_add_col_no_default_ok_when_empty(): t = CTable(Row) t.add_column("weight", blosc2.float64()) t.append((1, 2.0, True, 3.0)) assert t["weight"][0] == pytest.approx(3.0) -def test_add_column_without_default_on_non_empty_table_raises(): +def test_add_col_no_default_raises_non_empty(): t = CTable(Row, new_data=DATA10) with pytest.raises(ValueError, match="requires a default"): t.add_column("weight", blosc2.float64()) @@ -387,7 +387,7 @@ def test_add_column_values_skips_deleted_rows(): np.testing.assert_array_equal(t["id"][:], np.arange(2, 10)) -def test_add_column_values_keeps_default_for_later_rows(): +def test_add_col_values_keeps_default_later(): t = CTable(Row, new_data=DATA10) t.add_column("weight", blosc2.field(blosc2.float64(), default=9.0), values=[1.0] * 10) t.append((10, 0.0, True, 0.0)) @@ -432,7 +432,7 @@ def test_add_column_values_vlstring_skips_deleted_rows(): assert list(t["s"][:]) == vals -def test_add_column_default_vlstring_skips_deleted_rows(): +def test_add_col_vlstring_skips_deleted_rows(): t = CTable(Row, new_data=DATA10) t.delete([0, 1]) t.add_column("s", blosc2.field(blosc2.vlstring(), default="z")) diff --git a/tests/ctable/test_schema_specs.py b/tests/ctable/test_schema_specs.py index d78227ef7..349999320 100644 --- a/tests/ctable/test_schema_specs.py +++ b/tests/ctable/test_schema_specs.py @@ -200,7 +200,7 @@ def test_complex128_metadata_dict(): assert complex128().to_metadata_dict() == {"kind": "complex128"} -def test_ndarray_metadata_dict_normalizes_numpy_scalar_null_value(): +def test_ndarray_metadata_normalizes_np_scalar(): spec = blosc2.ndarray((2,), dtype=np.int16, null_value=np.int16(123)) d = spec.to_metadata_dict() diff --git a/tests/ctable/test_schema_validation.py b/tests/ctable/test_schema_validation.py index b170caf6f..882b5bf57 100644 --- a/tests/ctable/test_schema_validation.py +++ b/tests/ctable/test_schema_validation.py @@ -66,7 +66,7 @@ def test_append_default_fill(): assert t[0].id == 5 -def test_append_omitted_no_default_column_raises_clear_error(): +def test_append_omitted_no_default_raises(): t = CTable(Row, expected_size=5) with pytest.raises(ValueError, match="no default declared"): t.append(()) @@ -109,14 +109,14 @@ def test_extend_le_violation(): t.extend(data) -def test_extend_omitted_columns_with_defaults_are_filled(): +def test_extend_omitted_defaults_are_filled(): t = CTable(Row, expected_size=10) t.extend({"id": [1, 2]}) assert list(t["score"][:]) == [0.0, 0.0] assert list(t["active"][:]) == [True, True] -def test_extend_omitted_no_default_column_raises_clear_error(): +def test_extend_omitted_no_default_raises(): t = CTable(Row, expected_size=10) with pytest.raises(ValueError, match="no default declared"): t.extend({"score": [1.0, 2.0]}) diff --git a/tests/ctable/test_sort_by.py b/tests/ctable/test_sort_by.py index dc860875a..a0c2d6f3d 100644 --- a/tests/ctable/test_sort_by.py +++ b/tests/ctable/test_sort_by.py @@ -82,7 +82,7 @@ def test_sort_accepts_nested_column_selector_from_view(): np.testing.assert_array_equal(s["trip.sec"][:], [1, 2, 3, 4]) -def test_sort_projected_view_with_dictionary_column_above_default_capacity(): +def test_sort_projected_view_dict_over_capacity(): n = 5000 data = [(i, n - i, f"label-{i % 7}") for i in range(n)] t = CTable(DictSortRow, new_data=data) @@ -97,7 +97,7 @@ def test_sort_projected_view_with_dictionary_column_above_default_capacity(): assert "label" in str(sorted_view) -def test_sort_accepts_column_selectors_in_multi_key_list(): +def test_sort_accepts_col_selectors_multi_key(): t = CTable(Row, new_data=DATA) s = t.sort_by([t.score, t.id], ascending=[True, False]) @@ -377,7 +377,7 @@ def _loaded_columns(table) -> set[str]: return {name for name in table.col_names if dict.__contains__(table._cols, name)} -def test_sort_unprojected_view_opens_only_needed_columns(tmp_path): +def test_sort_unprojected_opens_needed_cols(tmp_path): """``where(cond).sort_by(key)`` without ``columns=`` used to gather every column of the view (~30x slower than projecting first). It must open only the condition and sort-key columns, deferring the rest until read.""" diff --git a/tests/ctable/test_table_persistency.py b/tests/ctable/test_table_persistency.py index 00e4d5c4c..71abf81b5 100644 --- a/tests/ctable/test_table_persistency.py +++ b/tests/ctable/test_table_persistency.py @@ -944,7 +944,7 @@ class VLRow: assert t2["data"][:] == [b"bin2", b"bin3"] -def test_ctable_from_cframe_rejects_non_embedstore_cframe(): +def test_from_cframe_rejects_non_embedstore(): """Passing an NDArray cframe raises ValueError.""" nd = blosc2.arange(10) cframe = nd.to_cframe() diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index 0a6feb5ef..927c83bc3 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -196,7 +196,7 @@ def test_utf8_array_extend_many_rows_no_dropped_rows(): assert list(arr[:]) == values -def test_utf8_array_extend_none_straddles_chunk_boundary(): +def test_utf8_array_extend_none_straddles_chunk(): from blosc2._utf8_array import _FLUSH_ROWS, UTF8Array values = [f"v{i}" for i in range(_FLUSH_ROWS + 2)] @@ -208,7 +208,7 @@ def test_utf8_array_extend_none_straddles_chunk_boundary(): assert list(arr[:]) == expected -def test_utf8_array_extend_append_interleaved_before_flush(): +def test_utf8_array_extend_append_interleaved(): from blosc2._utf8_array import UTF8Array arr = UTF8Array(blosc2.utf8()) @@ -230,7 +230,7 @@ def test_utf8_array_extend_ascii_nul_byte_preserved(): assert list(arr[:]) == values -def test_utf8_array_extend_multi_mb_strings_bounded_flush(): +def test_utf8_array_extend_multi_mb_bounded(): """~20 multi-MB ASCII strings: char-count flush bound is checked once per _FLUSH_ROWS-sized chunk (not per row), so this overshoots _FLUSH_CHARS by at most one chunk before flushing -- confirm read-back @@ -292,7 +292,7 @@ def test_utf8_array_bulk_read_kernel_and_fallback(force_kernel_mode): assert list(got) == SAMPLE -def test_utf8_array_bulk_read_matches_python_ground_truth(force_kernel_mode): +def test_utf8_array_bulk_read_matches_python(force_kernel_mode): """A wider mix of byte lengths and edge cases than SAMPLE: many distinct ASCII/multi-byte/empty/NUL-bearing values, read back in one bulk span.""" from blosc2._utf8_array import UTF8Array @@ -306,7 +306,7 @@ def test_utf8_array_bulk_read_matches_python_ground_truth(force_kernel_mode): assert list(arr[:]) == values -def test_ctable_utf8_extend_and_read_kernel_and_fallback(force_kernel_mode): +def test_ctable_utf8_extend_read_two_routes(force_kernel_mode): t = make_table() values = t["name"][:] assert values.dtype == STRING_DTYPE @@ -314,7 +314,7 @@ def test_ctable_utf8_extend_and_read_kernel_and_fallback(force_kernel_mode): @pytest.mark.parametrize("ext", [".b2z", ".b2d"]) -def test_ctable_utf8_persistence_roundtrip_kernel_and_fallback(tmp_path, ext, force_kernel_mode): +def test_ctable_utf8_persist_two_routes(tmp_path, ext, force_kernel_mode): urlpath = str(tmp_path / f"utf8_kernel_mode{ext}") t = make_table(urlpath=urlpath, mode="w") t.close() @@ -363,7 +363,7 @@ def test_utf8_array_extend_matches_python_ground_truth(force_write_kernel_mode): assert list(arr[:]) == values -def test_utf8_array_extend_ascii_nul_byte_kernel_and_fallback(force_write_kernel_mode): +def test_utf8_array_extend_nul_two_routes(force_write_kernel_mode): from blosc2._utf8_array import UTF8Array values = ["nul\x00in", "plain", "\x00leading", "trailing\x00"] @@ -373,7 +373,7 @@ def test_utf8_array_extend_ascii_nul_byte_kernel_and_fallback(force_write_kernel assert list(arr[:]) == values -def test_utf8_array_extend_multi_mb_string_kernel_and_fallback(force_write_kernel_mode): +def test_utf8_array_extend_mb_two_routes(force_write_kernel_mode): """A single multi-MB value alongside short ones -- sanity-checks the total-length/offset accumulation in the compiled kernel's two passes.""" from blosc2._utf8_array import UTF8Array @@ -390,7 +390,7 @@ def test_ctable_utf8_extend_kernel_and_fallback(force_write_kernel_mode): assert list(t["name"][:]) == SAMPLE -def test_utf8_array_extend_lone_surrogate_raises_and_recovers(force_write_kernel_mode): +def test_utf8_array_extend_surrogate_recovers(force_write_kernel_mode): """A lone surrogate is invalid UTF-8: flush() must raise UnicodeEncodeError, matching str.encode('utf-8')'s own behavior, and the array must remain usable afterwards -- a regression test for the @@ -515,7 +515,7 @@ def test_ctable_utf8_add_column_values(): assert list(t["note"][:]) == ["x", "yy", "zzz"] -def test_ctable_utf8_add_column_values_from_computed_expression(): +def test_ctable_utf8_add_col_values_from_expr(): """The documented round trip: compute on = ""]["name"][:]) == ["", "a", "zzz"] -def test_ctable_utf8_ordering_multibyte_byte_length_boundaries(): +def test_ctable_utf8_ordering_multibyte_bounds(): """1-, 2-, and 3-byte UTF-8 encodings must byte-compare in code-point order (code points 0x7A < 0xE9 < 0x65E5).""" assert "z" < "é" < "日" @@ -864,7 +864,7 @@ def test_ctable_utf8_ordering_probe_equals_sentinel(): assert None not in got -def test_ctable_utf8_scalar_comparison_view_and_deleted_rows(): +def test_ctable_utf8_scalar_cmp_view_deletes(): """The predicate mask is physical-length; it must stay correct through a view and after rows have been deleted (live-row mask intersection).""" t = make_table(["paris", "london", "paris", "tokyo", "berlin", "paris"]) @@ -891,7 +891,7 @@ def test_ctable_utf8_startswith_endswith(): # --------------------------------------------------------------------------- -def test_utf8_factorize_span_matches_np_unique_contract(): +def test_utf8_factorize_span_matches_np_unique(): """The raw-bytes factorization keeps the np.unique contract: uniques sorted ascending, codes indexing them. Ground truth is Python's set — numpy's np.unique on StringDType merges strings differing only after an @@ -924,7 +924,7 @@ def test_utf8_factorizer_cross_span_codes_are_global(): assert c1[1] == c2[1] -def test_ctable_utf8_groupby_many_byte_lengths_and_non_ascii(): +def test_ctable_utf8_groupby_lengths_non_ascii(): rng = np.random.default_rng(3) pool = ["", "a", "bb", "café", "日本語のテキスト", "x" * 2000, "münchen"] names = [pool[i] for i in rng.integers(0, len(pool), 3000)] @@ -1101,7 +1101,7 @@ def test_ctable_utf8_sort_inplace(): assert list(t["name"][:]) == ["a", "b", "c"] -def test_ctable_utf8_sort_multi_key_with_bystander_utf8_column(): +def test_ctable_utf8_sort_multi_key_bystander(): """A non-key utf8 column in the same table must be reordered along with the sort, not just the sort key itself.""" @@ -1137,7 +1137,7 @@ class TwoCols: @pytest.mark.parametrize("ext", [".b2z", ".b2d"]) -def test_ctable_utf8_sort_inplace_persists_after_reopen(tmp_path, ext): +def test_ctable_utf8_sort_inplace_persists(tmp_path, ext): """Regression: sort_by(inplace=True) on a file-backed table must write the sorted utf8 rows through to the store, keeping them aligned with the other (on-disk-sorted) columns after close/reopen.""" @@ -1174,7 +1174,7 @@ def test_ctable_utf8_compact_persists_after_reopen(tmp_path, ext): t2.close() -def test_ctable_utf8_setitem_persisted_shifts_survive_reopen(tmp_path): +def test_ctable_utf8_setitem_shifts_reopen(tmp_path): """__setitem__ on persisted rows shifts the byte blob in place; longer, shorter, equal-length, and empty replacements must all round-trip.""" urlpath = str(tmp_path / "utf8_setitem.b2d") @@ -1305,7 +1305,7 @@ def test_utf8_from_arrow_nulls_use_sentinel(): assert t["name"].null_count() == 1 -def test_utf8_from_arrow_fixed_width_when_max_length_given(): +def test_utf8_from_arrow_fixed_width_max_len(): pa = pytest.importorskip("pyarrow") at = pa.table({"name": pa.array(["hi", "there"], type=pa.string())}) t = CTable.from_arrow(at.schema, at.to_batches(), string_max_length=32) @@ -1341,7 +1341,7 @@ def test_utf8_array_constructor_with_spec_and_nulls(): assert list(arr[:]) == ["a", "", "c"] -def test_utf8_array_constructor_rejects_none_without_nullable_spec(): +def test_utf8_array_ctor_rejects_none_if_not_null(): with pytest.raises(TypeError, match="not nullable"): blosc2.utf8_array(["a", None]) @@ -1367,7 +1367,7 @@ def test_ctable_utf8_where_expression_equality(): assert list(t.where("name != 'hello'")["name"][:]) == ["help", "world", "café"] -def test_ctable_utf8_where_expression_matches_operator_form(): +def test_ctable_utf8_where_expr_vs_operator(): t = make_table(["paris", "london", "tokyo", "paris"]) for value in ("paris", "tokyo", "absent"): expr = list(t.where(f"name == '{value}'")["x"][:]) @@ -1382,7 +1382,7 @@ def test_ctable_utf8_where_expression_predicates(): assert list(t.where("contains(name, 'l')")["name"][:]) == ["hello", "help", "world"] -def test_ctable_utf8_where_expression_mixes_with_numeric_columns(): +def test_ctable_utf8_where_expr_mixes_numeric(): t = make_table(["a", "b", "c", "d"]) assert list(t.where("(name == 'b') | (x > 2)")["name"][:]) == ["b", "d"] assert list(t.where("(name != 'a') & (x < 2)")["name"][:]) == ["b"] @@ -1399,7 +1399,7 @@ def test_ctable_utf8_where_expression_runs_on_miniexpr(): assert list(got[:3]) == [True, True, False] -def test_ctable_utf8_where_expression_spans_many_widths(): +def test_ctable_utf8_where_expr_many_widths(): # Exercises the power-of-two width bucketing: values straddle several # buckets and one of them is past the 255-byte typesize cap. values = ["a", "bb", "x" * 40, "y" * 300, "café", ""] * 30 @@ -1410,7 +1410,7 @@ def test_ctable_utf8_where_expression_spans_many_widths(): ] -def test_ctable_utf8_where_expression_splits_oversized_spans(): +def test_ctable_utf8_where_expr_splits_spans(): # A single long row would size the whole span's '")["x"][:]) == [] -def test_ctable_utf8_where_expression_nulls_match_operator_form(): +def test_ctable_utf8_where_expr_nulls_operator(): t = _nullable_table(["hello", None, "help", None, "world"]) for value in ("hello", "world", ""): assert list(t.where(f"name == '{value}'")["x"][:]) == list(t[t.name == value]["x"][:]) @@ -1523,7 +1523,7 @@ def test_ctable_utf8_where_expression_all_null_column(): assert list(t.where("name != 'hello'")["x"][:]) == [] -def test_ctable_utf8_where_expression_null_count_zero_fast_path(): +def test_ctable_utf8_where_expr_no_nulls_fast(): # A nullable column with no actual nulls must not mask anything away. t = _nullable_table(["hello", "help", "world"]) assert list(t.where("startswith(name, 'hel')")["x"][:]) == [0, 1] @@ -1574,7 +1574,7 @@ def test_ctable_utf8_scalar_predicates_match_python(expr, predicate): ("startswith(name, 'hel') | (name == 'zz')", False), ], ) -def test_ctable_utf8_scalar_predicates_skip_the_span_driver(expr, rewritten_away): +def test_ctable_utf8_preds_skip_span_driver(expr, rewritten_away): """The raw-byte scan is several times cheaper than decode -> miniexpr. Correctness alone would not notice the difference, so assert on which route @@ -1587,7 +1587,7 @@ def test_ctable_utf8_scalar_predicates_skip_the_span_driver(expr, rewritten_away assert (remaining == []) is rewritten_away -def test_ctable_utf8_rewritten_predicate_matches_span_driver(): +def test_ctable_utf8_rewritten_pred_vs_driver(): """Both routes must agree, including on nulls and on the sentinel spelling.""" values = ["hello", None, "help", None, "world"] t = _nullable_table(values) @@ -1597,7 +1597,7 @@ def test_ctable_utf8_rewritten_predicate_matches_span_driver(): assert fast == slow, expr -def test_ctable_utf8_scalar_predicate_literal_with_operator_chars(): +def test_ctable_utf8_pred_literal_with_ops(): # The literal is parsed with ast.literal_eval, so quoted operators and # spaces inside it must not be mistaken for expression syntax. values = ["a == b", "x > y", "plain"] @@ -1606,7 +1606,7 @@ def test_ctable_utf8_scalar_predicate_literal_with_operator_chars(): assert list(t.where("name == 'x > y'")["x"][:]) == [1] -def test_ctable_utf8_scalar_predicate_on_view_and_after_delete(): +def test_ctable_utf8_pred_view_and_delete(): t = make_table(["paris", "london", "paris", "tokyo"]) t.delete([0]) assert list(t.where("name == 'paris'")["x"][:]) == [2] @@ -1614,7 +1614,7 @@ def test_ctable_utf8_scalar_predicate_on_view_and_after_delete(): assert list(view.where("name == 'paris'")["x"][:]) == [2] -def test_ctable_utf8_two_scalar_predicates_on_the_same_column(): +def test_ctable_utf8_two_preds_same_col(): t = make_table(["a", "b", "c", "d"]) assert list(t.where("(name > 'a') & (name < 'd')")["name"][:]) == ["b", "c"] @@ -1665,7 +1665,7 @@ def test_utf8_array_comparison_edge_cases(): assert isinstance(hash(arr), int) -def test_bare_utf8_array_expression_uses_the_span_driver(): +def test_bare_utf8_expr_uses_span_driver(): """A bare UTF8Array must not evaluate through the NumPy slices_eval path. That path returns correct-looking values while never reaching miniexpr, @@ -1685,7 +1685,7 @@ def test_bare_utf8_array_expression_uses_the_span_driver(): np.testing.assert_array_equal(mask, [True, False, True]) -def test_bare_utf8_array_expression_with_mixed_operands(): +def test_bare_utf8_expr_mixed_operands(): arr = blosc2.utf8_array(["hello", "world", "héllo"]) other = blosc2.utf8_array(["A", "B", "C"]) joined = blosc2.lazyexpr("a + b", {"a": arr, "b": other}).compute(strict_miniexpr=True) @@ -1720,7 +1720,7 @@ def test_bare_utf8_array_expression_splits_spans(span_rows, budget, monkeypatch) assert list(result[:]) == ["x=" + v for v in values] -def test_bare_utf8_array_expression_rejects_unsupported_forms(): +def test_bare_utf8_expr_rejects_unsupported(): arr = blosc2.utf8_array(["a", "b"]) lazy = blosc2.lazyexpr("upper(a)", {"a": arr}) @@ -1734,7 +1734,7 @@ def test_bare_utf8_array_expression_rejects_unsupported_forms(): blosc2.lazyexpr("upper(a)", {"a": arr}, where=(arr, arr)) -def test_ctable_utf8_index_survives_reopen_and_orders_nulls_last(tmp_path): +def test_ctable_utf8_index_reopen_nulls_last(tmp_path): """A persisted utf8 rank index must reopen and keep nulls at the end.""" from dataclasses import make_dataclass @@ -1754,7 +1754,7 @@ def test_ctable_utf8_index_survives_reopen_and_orders_nulls_last(tmp_path): assert ordered[3] == reopened["name"].null_value # the null sentinel sorts last -def test_ctable_utf8_index_goes_stale_when_the_column_changes(): +def test_ctable_utf8_index_stale_on_change(): """Appending a value ahead of existing ones invalidates every rank.""" t = make_table(["pear", "banana"]) t.create_index("name", kind="full") @@ -1829,7 +1829,7 @@ def test_ctable_utf8_index_answers_scalar_predicates(nullable, tmp_path): np.testing.assert_array_equal(masks["index"][key], scanned, err_msg=f"{key}") -def test_ctable_utf8_index_predicate_falls_back_when_stale(tmp_path): +def test_ctable_utf8_index_pred_falls_back(tmp_path): """A stale rank index must not answer predicates from frozen ranks.""" from dataclasses import make_dataclass @@ -1872,7 +1872,7 @@ def test_utf8_astype_width_is_codepoints_not_bytes(): assert arr.astype().dtype == np.dtype("= 2) assert view.tags[:] == [[], None, ["z"]] diff --git a/tests/ctable/test_vlstring_vlbytes.py b/tests/ctable/test_vlstring_vlbytes.py index 768c1b8a2..62947526e 100644 --- a/tests/ctable/test_vlstring_vlbytes.py +++ b/tests/ctable/test_vlstring_vlbytes.py @@ -206,7 +206,7 @@ def test_scalar_varlen_array_nullable(): assert sva[3] is None -def test_scalar_varlen_array_rejects_none_when_not_nullable(): +def test_varlen_array_rejects_none_not_nullable(): spec = blosc2.vlstring(nullable=False) sva = _make_sva(spec) with pytest.raises(TypeError, match="not nullable"): @@ -313,7 +313,7 @@ def test_ctable_vlstring_column_is_not_list(): assert ct.text.is_varlen_scalar -def test_ctable_vlstring_column_null_count_non_nullable(): +def test_vlstring_null_count_non_nullable(): ct = blosc2.CTable(VLRow, new_data=ROWS) # Non-nullable: no Nones → null_count = 0 assert ct.text.null_count() == 0 @@ -395,7 +395,7 @@ def test_ctable_vlstring_copy_with_deletions_compact(): assert list(copied.text) == expected -def test_ctable_vlstring_copy_noncompact_preserves_tombstones(): +def test_vlstring_copy_keeps_tombstones(): ct = blosc2.CTable(VLRow, new_data=ROWS) ct.delete([1, 3]) copied = ct.copy(compact=False) @@ -441,7 +441,7 @@ def test_ctable_vlstring_backend_role_metadata(tmp_path): } -def test_ctable_constructor_reopens_vlstring_persistent_table(tmp_path): +def test_ctor_reopens_vlstring_persistent(tmp_path): urlpath = str(tmp_path / "vl_ctor_reopen.b2d") ct = blosc2.CTable(VLRow, new_data=ROWS[:2], urlpath=urlpath, mode="w") ct.close() @@ -668,7 +668,7 @@ def test_ctable_vlbytes_column_assign(): assert list(ct["data"][:]) == [bytes([i]) for i in range(len(ROWS))] -def test_ctable_vlstring_column_assign_skips_deleted_rows(): +def test_vlstring_assign_skips_deleted_rows(): ct = blosc2.CTable(VLRow, new_data=ROWS) ct.delete([1, 3]) ct["text"].assign(["p", "q", "r"]) @@ -676,7 +676,7 @@ def test_ctable_vlstring_column_assign_skips_deleted_rows(): assert list(ct["id"][:]) == [0, 2, 4] -def test_ctable_vlstring_column_assign_wrong_length_raises(): +def test_vlstring_assign_wrong_length_raises(): ct = blosc2.CTable(VLRow, new_data=ROWS) with pytest.raises(ValueError, match="requires 5 values"): ct["text"].assign(["too", "few"]) diff --git a/tests/ctable/test_where_expressions.py b/tests/ctable/test_where_expressions.py index 0850ef9a5..8f4c9d3a3 100644 --- a/tests/ctable/test_where_expressions.py +++ b/tests/ctable/test_where_expressions.py @@ -43,7 +43,7 @@ def test_where_column_arithmetic_can_be_composed(): np.testing.assert_array_equal(view.value[:], np.array([20, 30, 2], dtype=np.int32)) -def test_where_column_expression_accepts_transcendental_functions(): +def test_where_col_expr_accepts_transcendentals(): t = blosc2.CTable(Row, new_data=DATA) view = t.where(((t.value + 2) * blosc2.sin(t.category)) >= 10) @@ -51,7 +51,7 @@ def test_where_column_expression_accepts_transcendental_functions(): np.testing.assert_array_equal(view.value[:], np.array([10, 20], dtype=np.int32)) -def test_where_string_expression_accepts_transcendental_functions(): +def test_where_str_expr_accepts_transcendentals(): t = blosc2.CTable(Row, new_data=DATA) view = t.where("(value + 2) * sin(category) >= 10") @@ -59,7 +59,7 @@ def test_where_string_expression_accepts_transcendental_functions(): np.testing.assert_array_equal(view.value[:], np.array([10, 20], dtype=np.int32)) -def test_where_string_expression_can_reference_computed_columns(): +def test_where_str_expr_uses_computed_cols(): t = blosc2.CTable(Row, new_data=DATA) t.add_computed_column("score", "value * category") diff --git a/tests/ndarray/test_dsl_kernels.py b/tests/ndarray/test_dsl_kernels.py index a001a474e..0023147f5 100644 --- a/tests/ndarray/test_dsl_kernels.py +++ b/tests/ndarray/test_dsl_kernels.py @@ -235,7 +235,7 @@ def test_dsl_kernel_loop_kept_as_full_dsl_function(): np.testing.assert_allclose(res[...], expected, rtol=1e-5, atol=1e-6) -def test_dsl_kernel_integer_ops_kept_as_full_dsl_function(): +def test_kernel_integer_ops_kept_as_dsl(): assert kernel_integer_ops.dsl_source is not None assert "def kernel_integer_ops(x, y):" in kernel_integer_ops.dsl_source assert kernel_integer_ops.input_names == ["x", "y"] @@ -286,7 +286,7 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, assert res.shape == shape -def test_dsl_kernel_with_no_inputs_works_with_explicit_shape(): +def test_kernel_no_inputs_with_explicit_shape(): assert kernel_index_ramp_no_inputs.dsl_source is not None assert "def kernel_index_ramp_no_inputs():" in kernel_index_ramp_no_inputs.dsl_source assert kernel_index_ramp_no_inputs.input_names == [] @@ -308,12 +308,12 @@ def test_dsl_kernel_with_no_inputs_sum_returns_scalar(): np.testing.assert_allclose(result, expected, rtol=0.0, atol=0.0) -def test_dsl_kernel_with_no_inputs_requires_shape_or_out(): +def test_kernel_no_inputs_needs_shape_or_out(): with pytest.raises(ValueError, match="shape"): _ = blosc2.lazyudf(kernel_index_ramp_no_inputs, (), dtype=np.float32) -def test_dsl_kernel_with_no_inputs_handles_windows_dtype_policy(monkeypatch): +def test_kernel_no_inputs_windows_dtype_policy(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -326,7 +326,7 @@ def test_dsl_kernel_with_no_inputs_handles_windows_dtype_policy(monkeypatch): np.testing.assert_equal(res, expected) -def test_dsl_kernel_index_symbols_float_cast_matches_expected_ramp(): +def test_kernel_index_float_cast_matches_ramp(): shape = (32, 5) x2 = blosc2.zeros(shape, dtype=np.float32) expr = blosc2.lazyudf(kernel_index_ramp_float_cast, (x2,), dtype=np.float32) @@ -335,7 +335,7 @@ def test_dsl_kernel_index_symbols_float_cast_matches_expected_ramp(): np.testing.assert_allclose(res, expected, rtol=0.0, atol=0.0) -def test_dsl_kernel_index_symbols_float_cast_uses_miniexpr_fast_path(monkeypatch): +def test_kernel_index_float_cast_fast_path(monkeypatch): original_set_pref_expr = blosc2.NDArray._set_pref_expr captured = {"calls": 0, "expr": None} @@ -364,7 +364,7 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, ) -def test_dsl_kernel_index_symbols_int_cast_matches_expected_ramp(): +def test_kernel_index_int_cast_matches_ramp(): shape = (32, 5) x2 = blosc2.zeros(shape, dtype=np.float32) expr = blosc2.lazyudf(kernel_index_ramp_int_cast, (x2,), dtype=np.int64) @@ -390,7 +390,7 @@ def test_dsl_kernel_bool_cast_numeric_matches_expected(): np.testing.assert_equal(res, expected) -def test_dsl_kernel_full_control_flow_kept_as_dsl_function(): +def test_kernel_control_flow_kept_as_dsl(): assert kernel_control_flow_full.dsl_source is not None assert "def kernel_control_flow_full(x, y):" in kernel_control_flow_full.dsl_source assert "for i in range(4):" in kernel_control_flow_full.dsl_source @@ -453,7 +453,7 @@ def test_dsl_kernel_accepts_scalar_param_per_call(): np.testing.assert_allclose(res[...], expected, rtol=1e-5, atol=1e-6) -def test_dsl_kernel_scalar_param_keeps_miniexpr_fast_path(monkeypatch): +def test_kernel_scalar_param_keeps_fast_path(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -501,7 +501,7 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr -def test_dsl_kernel_scalar_float_cast_inlined_without_float_call(monkeypatch): +def test_kernel_scalar_float_cast_inlined(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -536,7 +536,7 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr -def test_dsl_kernel_scalar_only_inputs_route_through_fast_eval(monkeypatch): +def test_kernel_scalar_only_via_fast_eval(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -568,7 +568,8 @@ def fail_slices_eval(*args, **kwargs): np.testing.assert_equal(res[...], np.zeros(shape, dtype=np.float32)) -def test_dsl_kernel_scalar_only_inputs_specialization_injects_dummy_operand(monkeypatch): +def test_kernel_scalar_only_injects_dummy(monkeypatch): + """Scalar-only inputs specialize to a kernel with a dummy operand injected.""" import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -600,7 +601,7 @@ def failing_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr -def test_dsl_kernel_two_scalar_params_start_step_linear_ramp(): +def test_kernel_two_scalar_params_ramp(): shape = (9, 7) start = np.float32(2.5) step = np.float32(0.75) @@ -612,7 +613,7 @@ def test_dsl_kernel_two_scalar_params_start_step_linear_ramp(): np.testing.assert_allclose(res[...], expected, rtol=0.0, atol=0.0) -def test_dsl_kernel_three_scalar_params_start_stop_nitems_ramp(): +def test_kernel_three_scalar_params_ramp(): shape = (20, 25) start = np.float64(1.0) stop = np.float64(2.0) @@ -628,7 +629,7 @@ def test_dsl_kernel_three_scalar_params_start_stop_nitems_ramp(): np.testing.assert_allclose(res[...], expected, rtol=0.0, atol=0.0) -def test_dsl_kernel_float_cast_with_negative_scalar_param(): +def test_kernel_float_cast_negative_scalar(): shape = (10, 100) start = -10 stop = 10 @@ -643,7 +644,7 @@ def test_dsl_kernel_float_cast_with_negative_scalar_param(): np.testing.assert_allclose(res[...], expected, rtol=1e-6, atol=1e-6) -def test_dsl_kernel_float_cast_with_flat_idx_no_segfault_subprocess(): +def test_kernel_float_cast_flat_idx_no_crash(): if blosc2.IS_WASM: pytest.skip("subprocess is not supported on emscripten/wasm32") @@ -679,7 +680,7 @@ def kernel(start, stop, nitems): assert "ok" in result.stdout -def test_dsl_kernel_scalar_constant_subexpr_runtime_no_segfault(tmp_path): +def test_kernel_scalar_const_subexpr_no_crash(tmp_path): if blosc2.IS_WASM: pytest.skip("subprocess is not supported on emscripten/wasm32") @@ -709,7 +710,7 @@ def kernel_const_subexpr(x, start, step): assert "ok" in result.stdout -def test_dsl_kernel_miniexpr_failure_raises_even_with_strict_disabled(monkeypatch): +def test_kernel_failure_raises_strict_off(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -736,7 +737,7 @@ def failing_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr -def test_dsl_kernel_miniexpr_failure_includes_backend_error_details(monkeypatch): +def test_kernel_failure_includes_backend_error(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -761,7 +762,7 @@ def failing_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr -def test_dsl_kernel_miniexpr_failure_prefers_validate_dsl_error(monkeypatch): +def test_kernel_failure_prefers_validate_error(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -843,7 +844,7 @@ def test_jit_backend_pragma_wrapping_dsl_source(): kernel_fallback_tuple_assign, ], ) -def test_dsl_kernel_flawed_syntax_detected_fallback_callable(kernel): +def test_kernel_flawed_syntax_falls_back(kernel): assert kernel.dsl_source is not None assert kernel.input_names == ["x", "y"] assert kernel.dsl_error is not None @@ -859,7 +860,7 @@ def test_dsl_kernel_flawed_syntax_detected_fallback_callable(kernel): ) -def test_dsl_kernel_ternary_rejected_with_actionable_error(): +def test_kernel_ternary_rejected_with_hint(): assert kernel_fallback_ternary.dsl_source is not None assert kernel_fallback_ternary.input_names == ["x"] assert kernel_fallback_ternary.dsl_error is not None @@ -1061,7 +1062,7 @@ def test_dsl_save_dictstore_operands(tmp_path): # G3 (variable name colliding with miniexpr codegen identifier) --- -def test_dsl_kernel_semicolon_joined_statements_rejected(): +def test_kernel_semicolon_statements_rejected(): # Source built from a string so the formatter cannot rewrite the ';'-join away. result = validate_dsl( kernel_from_source("def k(a, b):\n x = a * a; y = b * b\n return x + y\n", "k") @@ -1178,7 +1179,7 @@ def _numpy_operand_kernel(x, y): (13, 17, 19), # 3-D: odd shape ], ) -def test_dsl_kernel_numpy_operands_match_ndarray_reference(shape): +def test_kernel_numpy_operands_match_ndarray(shape): rng = np.random.default_rng(0) a = rng.random(shape).astype(np.float64) b = rng.random(shape).astype(np.float64) @@ -1186,7 +1187,7 @@ def test_dsl_kernel_numpy_operands_match_ndarray_reference(shape): np.testing.assert_array_equal(res, _dsl_reference(_numpy_operand_kernel, (a, b))) -def test_dsl_kernel_numpy_operands_mixed_dtype_promotes_output(): +def test_kernel_numpy_mixed_dtype_promotes(): rng = np.random.default_rng(1) a = (rng.random(10_007) * 10).astype(np.float32) b = (rng.random(10_007) * 10).astype(np.int64) @@ -1196,7 +1197,7 @@ def test_dsl_kernel_numpy_operands_mixed_dtype_promotes_output(): np.testing.assert_array_equal(res, ref) -def test_dsl_kernel_ndarray_operands_with_different_itemsize(): +def test_kernel_ndarray_different_itemsize(): # Blocks are sized in bytes, so a float32 and an int64 operand get different # chunks/blocks by default; the DSL path has no slow fallback, so it used to # raise "slicing is not supported" whenever the grids diverged (which depends @@ -1219,7 +1220,7 @@ def test_dsl_kernel_mixed_ndarray_and_numpy_operand(): np.testing.assert_array_equal(res, ref) -def test_dsl_kernel_numpy_operands_f_ordered_and_strided(): +def test_kernel_numpy_f_ordered_and_strided(): shape = (20, 10) b = np.arange(np.prod(shape), dtype=np.float64).reshape(shape) ref = _dsl_reference(_numpy_operand_kernel, (b, b)) @@ -1234,14 +1235,14 @@ def test_dsl_kernel_numpy_operands_f_ordered_and_strided(): np.testing.assert_array_equal(res_strided, ref_strided) -def test_dsl_kernel_numpy_operand_non_native_endian_requires_miniexpr(): +def test_kernel_non_native_endian_needs_miniexpr(): a = np.arange(100, dtype=">f8").reshape(10, 10) b = np.arange(100, dtype=np.float64).reshape(10, 10) with pytest.raises(RuntimeError, match="NDArray or NumPy inputs"): blosc2.lazyudf(_numpy_operand_kernel, (a, b), dtype=None)[()] -def test_dsl_kernel_zero_input_dummy_operand_injection_still_works(): +def test_kernel_zero_input_dummy_injection(): @blosc2.dsl_kernel def ramp(start, step): return start + step * _i0 # noqa: F821 # DSL index symbol resolved by miniexpr @@ -1251,7 +1252,8 @@ def ramp(start, step): np.testing.assert_allclose(res, expected) -def test_dsl_kernel_numpy_out_matches_compute_and_honors_explicit_cparams(): +def test_kernel_numpy_out_matches_compute(): + """A NumPy `out` matches compute(), and an explicit cparams is honoured.""" a = np.arange(1000, dtype=np.float64) b = np.arange(1000, dtype=np.float64) * 0.5 lexpr = blosc2.lazyudf(_numpy_operand_kernel, (a, b), dtype=None) @@ -1264,7 +1266,7 @@ def test_dsl_kernel_numpy_out_matches_compute_and_honors_explicit_cparams(): assert res_explicit.schunk.cparams.clevel == 5 -def test_dsl_kernel_numpy_attribute_calls_are_rewritten_to_bare_names(): +def test_kernel_numpy_attr_calls_rewritten(): @blosc2.dsl_kernel def k(x, y): if x >= 0: @@ -1294,7 +1296,7 @@ def k(x, y): ("np.absolute(x)", "abs"), ], ) -def test_dsl_kernel_numpy_func_aliases_map_to_dsl_names(numpy_call, expected_dsl_name): +def test_kernel_numpy_aliases_map_to_dsl(numpy_call, expected_dsl_name): src = f"def k(x):\n if x >= 0:\n return {numpy_call}\n else:\n return -x\n" k = kernel_from_source(src) @@ -1307,7 +1309,7 @@ def test_dsl_kernel_numpy_func_aliases_map_to_dsl_names(numpy_call, expected_dsl np.testing.assert_allclose(res, expected) -def test_dsl_kernel_numpy_alias_not_rewritten_when_shadowed_by_parameter(): +def test_kernel_numpy_alias_shadowed_by_param(): # A parameter literally named "np" shadows the module -- the rewrite must # not mistake a per-call NDArray/scalar input for the NumPy module. src = "def k(np, y):\n return np * y\n" @@ -1320,7 +1322,7 @@ def test_dsl_kernel_numpy_alias_not_rewritten_when_shadowed_by_parameter(): np.testing.assert_allclose(res, a * b) -def test_dsl_kernel_numpy_call_without_alias_left_untouched(): +def test_kernel_numpy_call_no_alias_untouched(): # No import of numpy bound in the kernel's defining scope -- nothing to # rewrite, and the plain bare-name form still works unaffected. @blosc2.dsl_kernel diff --git a/tests/ndarray/test_getitem.py b/tests/ndarray/test_getitem.py index 2d4581de0..92fdede28 100644 --- a/tests/ndarray/test_getitem.py +++ b/tests/ndarray/test_getitem.py @@ -157,7 +157,7 @@ def test_lazyexpr_where_full_slice_no_recursion(): np.testing.assert_allclose(a[a < 5][:], expected) -def test_lazyexpr_where_full_slice_persisted_reuses_shared_chunk_cache(tmp_path): +def test_where_full_slice_reuses_shared_cache(tmp_path): nitems = 60_000 expected = np.linspace(0, 1, nitems) a = blosc2.asarray( @@ -172,7 +172,7 @@ def test_lazyexpr_where_full_slice_persisted_reuses_shared_chunk_cache(tmp_path) blosc2.set_nthreads(old_nthreads) -def test_lazyexpr_where_full_slice_cached_repeat_avoids_full_mask_scan(monkeypatch): +def test_where_full_slice_repeat_avoids_scan(monkeypatch): nitems = 60_000 expected = np.arange(5, dtype=np.int64) a = blosc2.asarray(np.arange(nitems, dtype=np.int64), chunks=(20_000,)) @@ -188,7 +188,7 @@ def test_lazyexpr_where_full_slice_cached_repeat_avoids_full_mask_scan(monkeypat @pytest.mark.parametrize("mode", ["r", "a"]) -def test_lazyexpr_where_full_slice_persistent_uses_hot_cache_without_persisting(tmp_path, monkeypatch, mode): +def test_where_full_slice_hot_cache_no_persist(tmp_path, monkeypatch, mode): nitems = 60_000 expected = np.arange(5, dtype=np.int64) urlpath = tmp_path / "persisted_readonly.b2nd" @@ -303,7 +303,8 @@ def test_take_1d_sparse_path_negative_indices(): np.testing.assert_array_equal(a[idx], npa[idx]) -def test_take_1d_sparse_path_structured_non_behaved_partitions(): +def test_take_1d_sparse_structured_partitions(): + """The 1-D sparse path, on structured dtypes with non-behaved partitions.""" npa = np.empty((100,), dtype=[("a", np.int32), ("b", np.int32)]) npa["a"] = np.arange(1, 101) npa["b"] = np.arange(200, 100, -1) @@ -328,7 +329,7 @@ def test_ndarray_take_1d_matches_numpy(): np.testing.assert_array_equal(result[()], np.take(npa, idx)) -def test_ndarray_take_axis_with_nd_indices_matches_numpy(): +def test_take_axis_nd_indices_matches_numpy(): npa = np.arange(3 * 4 * 5, dtype=np.int32).reshape(3, 4, 5) a = blosc2.asarray(npa, chunks=(2, 2, 3)) idx = np.array([[3, 0], [1, -1]], dtype=np.int64) @@ -342,7 +343,7 @@ def test_ndarray_take_axis_with_nd_indices_matches_numpy(): np.testing.assert_array_equal(top_level_result[()], expected) -def test_ndarray_take_axis_none_nd_fallback_matches_numpy(): +def test_take_axis_none_nd_matches_numpy(): npa = np.arange(3 * 4 * 5, dtype=np.int32).reshape(3, 4, 5) a = blosc2.asarray(npa, chunks=(2, 2, 3)) idx = np.array([[0, -1], [17, 5]], dtype=np.int64) @@ -648,7 +649,7 @@ def test_getitem_integer_array_out_of_bounds(): _ = a[[-4]] -def test_getitem_integer_array_still_uses_fancy_for_boolean(): +def test_getitem_int_array_fancy_for_boolean(): """Boolean arrays should NOT be routed through the sparse path.""" a = blosc2.asarray(np.arange(12, dtype=np.int32).reshape(3, 4)) mask = np.array([True, False, True]) diff --git a/tests/ndarray/test_indexing.py b/tests/ndarray/test_indexing.py index b1a6837b1..e8606c04a 100644 --- a/tests/ndarray/test_indexing.py +++ b/tests/ndarray/test_indexing.py @@ -47,7 +47,7 @@ def test_scalar_index_matches_scan(kind): np.testing.assert_array_equal(indexed, data[(data >= 120_000) & (data < 125_000)]) -def test_opsi_index_accepts_non_multiple_chunk_and_block_lengths(): +def test_opsi_accepts_non_multiple_chunk_block(): rng = np.random.default_rng(42) data = rng.random(5_000, dtype=np.float64) arr = blosc2.asarray(data, chunks=(781,), blocks=(160,)) @@ -105,7 +105,7 @@ def test_opsi_optlevel_controls_chunk_multiplier(optlevel, expected_multiplier): (9, 4), ], ) -def test_chunk_local_indexes_optlevel_controls_chunk_multiplier(kind, optlevel, expected_multiplier): +def test_chunk_local_optlevel_sets_multiplier(kind, optlevel, expected_multiplier): rng = np.random.default_rng(44) data = rng.integers(0, 100_000, size=20_000, dtype=np.int64) arr = blosc2.asarray(data, chunks=(1_000,), blocks=(200,)) @@ -142,7 +142,7 @@ def test_structured_field_index_matches_scan(kind): np.testing.assert_array_equal(indexed, data[(data["id"] >= 48_000) & (data["id"] < 51_000)]) -def test_module_level_will_use_index_matches_lazyexpr_method(): +def test_module_will_use_index_matches_method(): import blosc2.indexing as indexing indexed = blosc2.asarray(np.arange(100_000, dtype=np.int64), chunks=(10_000,), blocks=(2_000,)) @@ -218,7 +218,8 @@ def test_index_accessor_compact_updates_live_view(tmp_path): assert reopened.index("a")["full"]["runs"] == [] -def test_gather_positions_by_block_avoids_whole_chunk_fallback_for_multi_block_reads(monkeypatch): +def test_gather_by_block_avoids_chunk_fallback(monkeypatch): + """A read spanning several blocks gathers by block, not by whole chunk.""" import blosc2.indexing as indexing class FakeSource: @@ -343,7 +344,7 @@ def test_bucket_numeric_dtype_query_matches_scan(dtype): np.testing.assert_array_equal(indexed, expected) -def test_numeric_unsupported_dtype_fallback_matches_scan(): +def test_unsupported_dtype_fallback_vs_scan(): values = (np.arange(2_000, dtype=np.float16) / np.float16(10)).astype(np.float16) arr = blosc2.asarray(values, chunks=(500,), blocks=(100,)) @@ -499,7 +500,7 @@ def test_cross_column_exact_refinement_with_full_index(tmp_path, persistent): np.testing.assert_array_equal(indexed, expected) -def test_summary_threaded_downstream_order_matches_scan(monkeypatch): +def test_summary_threaded_order_matches_scan(monkeypatch): dtype = np.dtype([("id", np.int64), ("payload", np.int32)]) data = np.zeros(240_000, dtype=dtype) data["id"] = np.arange(data.shape[0], dtype=np.int64) @@ -586,7 +587,7 @@ def test_persistent_index_survives_reopen(tmp_path, kind): @pytest.mark.parametrize("kind", ["bucket", "partial", "full"]) -def test_default_ooc_persistent_index_matches_scan_and_rebuilds(tmp_path, kind): +def test_ooc_persistent_matches_scan_rebuilds(tmp_path, kind): path = tmp_path / f"indexed_ooc_{kind}.b2nd" rng = np.random.default_rng(7) dtype = np.dtype([("id", np.int64), ("payload", np.float32)]) @@ -616,7 +617,7 @@ def test_default_ooc_persistent_index_matches_scan_and_rebuilds(tmp_path, kind): @pytest.mark.parametrize("kind", ["bucket", "partial"]) -def test_persistent_chunk_local_ooc_builds_do_not_use_temp_memmap(tmp_path, kind): +def test_persistent_chunk_ooc_no_temp_memmap(tmp_path, kind): path = tmp_path / f"persistent_no_memmap_{kind}.b2nd" data = np.arange(120_000, dtype=np.int64) indexing = __import__("blosc2.indexing", fromlist=["_segment_row_count"]) @@ -636,7 +637,7 @@ def test_persistent_chunk_local_ooc_builds_do_not_use_temp_memmap(tmp_path, kind @pytest.mark.parametrize("kind", ["bucket", "partial"]) -def test_in_memory_chunk_local_ooc_builds_do_not_use_temp_memmap(kind): +def test_in_memory_chunk_ooc_no_temp_memmap(kind): data = np.arange(120_000, dtype=np.int64) indexing = __import__("blosc2.indexing", fromlist=["_segment_row_count"]) assert not hasattr(indexing, "_open_temp_memmap") @@ -706,7 +707,7 @@ def test_in_mem_override_disables_ooc_builder(kind): @pytest.mark.parametrize("use_expression", [False, True]) -def test_ultralight_ooc_build_does_not_materialize_full_target(monkeypatch, tmp_path, use_expression): +def test_ultralight_ooc_no_full_materialize(monkeypatch, tmp_path, use_expression): path = tmp_path / ("indexed_expr_ultralight.b2nd" if use_expression else "indexed_ultralight.b2nd") if use_expression: data = np.zeros(120_000, dtype=[("x", np.int64)]) @@ -729,7 +730,8 @@ def fail_values_for_target(array, target): @pytest.mark.parametrize("kind", ["bucket", "partial"]) -def test_chunk_local_ooc_intra_chunk_build_uses_thread_pool_when_threads_forced(monkeypatch, kind): +def test_intra_chunk_ooc_uses_thread_pool(monkeypatch, kind): + """The intra-chunk OOC build uses the thread pool when threads are forced.""" if blosc2.IS_WASM: pytest.skip("wasm32 does not use Python thread pools for index building") data = np.arange(48_000, dtype=np.int64) @@ -761,7 +763,7 @@ def map(self, fn, iterable): @pytest.mark.parametrize("kind", ["bucket", "partial"]) -def test_in_memory_chunk_local_build_uses_cparams_nthreads(monkeypatch, kind): +def test_in_memory_chunk_uses_cparams_threads(monkeypatch, kind): if blosc2.IS_WASM: pytest.skip("wasm32 does not use Python thread pools for index building") data = np.arange(48_000, dtype=np.int64) @@ -812,7 +814,7 @@ def test_persistent_chunk_local_sidecars_use_cparams(tmp_path, kind): assert sidecar.cparams.clevel == 2 -def test_intra_chunk_sort_run_matches_numpy_stable_order(): +def test_intra_chunk_sort_matches_np_stable(): indexing_ext = __import__("blosc2.indexing_ext", fromlist=["intra_chunk_sort_run"]) values = np.array([4.0, np.nan, 2.0, 2.0, np.nan, 1.0, 4.0], dtype=np.float64) @@ -823,7 +825,7 @@ def test_intra_chunk_sort_run_matches_numpy_stable_order(): np.testing.assert_array_equal(positions, order.astype(np.uint16, copy=False)) -def test_intra_chunk_merge_sorted_slices_matches_lexsort_merge(): +def test_intra_chunk_merge_matches_lexsort(): indexing_ext = __import__("blosc2.indexing_ext", fromlist=["intra_chunk_merge_sorted_slices"]) left_values = np.array([1.0, 2.0, 2.0, np.nan], dtype=np.float64) left_positions = np.array([0, 2, 3, 6], dtype=np.uint16) @@ -841,7 +843,7 @@ def test_intra_chunk_merge_sorted_slices_matches_lexsort_merge(): np.testing.assert_array_equal(merged_positions, all_positions[order]) -def test_intra_chunk_merge_sorted_slices_validates_lengths(): +def test_intra_chunk_merge_validates_lengths(): indexing_ext = __import__("blosc2.indexing_ext", fromlist=["intra_chunk_merge_sorted_slices"]) values = np.array([1.0, 2.0], dtype=np.float64) positions = np.array([0, 1], dtype=np.uint16) @@ -852,7 +854,7 @@ def test_intra_chunk_merge_sorted_slices_validates_lengths(): ) -def test_index_search_boundary_bounds_validates_lengths(): +def test_search_boundary_validates_lengths(): indexing_ext = __import__("blosc2.indexing_ext", fromlist=["index_search_boundary_bounds"]) starts = np.array([1, 3], dtype=np.int64) ends = np.array([2], dtype=np.int64) @@ -861,7 +863,7 @@ def test_index_search_boundary_bounds_validates_lengths(): indexing_ext.index_search_boundary_bounds(starts, ends, None, True, None, True) -def test_mutation_marks_index_stale_and_rebuild_restores_it(): +def test_mutation_marks_stale_rebuild_restores(): data = np.arange(50_000, dtype=np.int64) arr = blosc2.asarray(data, chunks=(5_000,), blocks=(1_000,)) arr.create_index(kind=blosc2.IndexKind.FULL) @@ -878,7 +880,7 @@ def test_mutation_marks_index_stale_and_rebuild_restores_it(): assert expr.will_use_index() is True -def test_full_index_reuses_primary_order_for_indices_and_sort(): +def test_full_index_reuses_primary_order(): dtype = np.dtype([("a", np.int64), ("b", np.int64)]) data = np.array( [(2, 9), (1, 8), (2, 7), (1, 6), (2, 5), (1, 4), (2, 3), (1, 2), (2, 1), (1, 0)], @@ -914,7 +916,7 @@ def test_persistent_scalar_argsort_uses_full_index(tmp_path): np.testing.assert_array_equal(result[:], np.argsort(data, kind="stable")) -def test_filtered_ordered_queries_support_cross_field_exact_indexes(): +def test_filtered_ordered_cross_field_indexes(): dtype = np.dtype([("a", np.int64), ("b", np.int64), ("payload", np.int32)]) data = np.array( [ @@ -1070,7 +1072,8 @@ def test_persistent_full_index_runs_survive_reopen(tmp_path): np.testing.assert_array_equal(expr.compute()[:], expected[expected_mask]) -def test_persistent_compact_full_positional_query_avoids_whole_sidecar_load(monkeypatch, tmp_path): +def test_compact_positional_no_sidecar_load(monkeypatch, tmp_path): + """A positional query on a persistent compact index reads no whole sidecar.""" path = tmp_path / "full_selective_ooc.b2nd" rng = np.random.default_rng(12) data = np.arange(120_000, dtype=np.int64) @@ -1104,7 +1107,7 @@ def guarded_load(array, token, category, name, sidecar_path): ("full", {("full", "values"), ("full", "positions")}), ], ) -def test_in_memory_positional_queries_avoid_whole_loading_index_payloads(monkeypatch, kind, blocked): +def test_in_memory_positional_no_full_load(monkeypatch, kind, blocked): data = np.arange(120_000, dtype=np.int64) arr = blosc2.asarray(data, chunks=(12_000,), blocks=(2_000,)) arr.create_index(kind=_public_kind(kind)) @@ -1257,7 +1260,7 @@ def test_append_keeps_expression_index_current(kind): np.testing.assert_array_equal(arr.sort(order="abs(x)")[:], all_data[expected_positions]) -def test_repeated_appends_keep_full_expression_index_current(): +def test_repeated_appends_keep_expr_index(): dtype = np.dtype([("x", np.int64), ("payload", np.int32)]) data = np.array([(-10, 0), (7, 1), (-3, 2), (1, 3)], dtype=dtype) arr = blosc2.asarray(data, chunks=(2,), blocks=(2,)) @@ -1280,7 +1283,7 @@ def test_repeated_appends_keep_full_expression_index_current(): np.testing.assert_array_equal(expr.compute()[:], expected[expected_mask]) -def test_compact_full_index_clears_runs_and_preserves_results(tmp_path): +def test_compact_clears_runs_keeps_results(tmp_path): path = tmp_path / "compact_full_runs.b2nd" dtype = np.dtype([("a", np.int64), ("b", np.int64)]) data = np.array([(3, 9), (1, 8), (2, 7), (1, 6)], dtype=dtype) @@ -1321,7 +1324,7 @@ def test_compact_full_index_clears_runs_and_preserves_results(tmp_path): np.testing.assert_array_equal(expr.compute()[:], expected[expected_mask]) -def test_compact_full_expression_index_preserves_results(): +def test_compact_expr_index_keeps_results(): dtype = np.dtype([("x", np.int64), ("payload", np.int32)]) data = np.array([(-10, 0), (7, 1), (-3, 2), (1, 3)], dtype=dtype) arr = blosc2.asarray(data, chunks=(2,), blocks=(2,)) @@ -1343,7 +1346,7 @@ def test_compact_full_expression_index_preserves_results(): np.testing.assert_array_equal(expr.compute()[:], expected[expected_mask]) -def test_forced_ooc_full_index_merge_preserves_sorted_sidecars(monkeypatch, tmp_path): +def test_forced_ooc_merge_keeps_sidecars(monkeypatch, tmp_path): path = tmp_path / "forced_ooc_full_merge.b2nd" rng = np.random.default_rng(14) data = np.arange(4096, dtype=np.int64) @@ -1413,7 +1416,7 @@ def test_full_ooc_run_items_env_overrides_optlevel(monkeypatch, tmp_path, optlev assert full["ooc_run_item_budget_source"] == "env" -def test_create_index_full_ooc_defaults_tmpdir_to_array_directory(monkeypatch, tmp_path): +def test_full_ooc_tmpdir_defaults_to_array(monkeypatch, tmp_path): path = tmp_path / "default_tmpdir_full.b2nd" data = np.arange(4096, dtype=np.int64) arr = blosc2.asarray(data, urlpath=path, mode="w", chunks=(256,), blocks=(64,)) @@ -1433,7 +1436,7 @@ def tracking_temporary_directory(*args, **kwargs): assert recorded["dir"] == str(path.parent.resolve()) -def test_create_sorted_index_full_ooc_uses_explicit_tmpdir(monkeypatch, tmp_path): +def test_sorted_full_ooc_uses_given_tmpdir(monkeypatch, tmp_path): path = tmp_path / "explicit_tmpdir_full.b2nd" custom_tmpdir = tmp_path / "custom-index-tmp" custom_tmpdir.mkdir() @@ -1458,7 +1461,7 @@ def tracking_temporary_directory(*args, **kwargs): @pytest.mark.parametrize("persistent", [False, True]) -def test_compact_full_index_rebuilds_navigation_without_whole_loading(monkeypatch, tmp_path, persistent): +def test_compact_rebuilds_nav_without_full_load(monkeypatch, tmp_path, persistent): dtype = np.dtype([("a", np.int64), ("b", np.int64)]) data = np.array([(3, 9), (1, 8), (2, 7), (1, 6)], dtype=dtype) kwargs = {"chunks": (2,), "blocks": (2,)} @@ -1493,7 +1496,7 @@ def guarded_load(array, token, category, name, sidecar_path): np.testing.assert_array_equal(expr.compute()[:], expected) -def test_persistent_large_run_full_query_uses_bounded_fallback(monkeypatch, tmp_path): +def test_large_run_query_bounded_fallback(monkeypatch, tmp_path): path = tmp_path / "large_run_fallback.b2nd" dtype = np.dtype([("id", np.int64), ("payload", np.int32)]) data = np.array([(10, 0), (20, 1), (30, 2), (40, 3)], dtype=dtype) @@ -1525,7 +1528,7 @@ def guarded_load(array, token, category, name, sidecar_path): np.testing.assert_array_equal(expr.compute()[:], expected) -def test_large_run_full_expression_query_uses_bounded_fallback(monkeypatch): +def test_large_run_expr_query_bounded_fallback(monkeypatch): dtype = np.dtype([("x", np.int64), ("payload", np.int32)]) data = np.array([(-10, 0), (7, 1), (-3, 2), (1, 3)], dtype=dtype) arr = blosc2.asarray(data, chunks=(4,), blocks=(2,)) @@ -1610,7 +1613,7 @@ def test_canonical_digest_differs_on_order_change(): assert indexing._query_cache_digest(d1) != indexing._query_cache_digest(d2) -def test_canonical_digest_preserves_order_field_sequence(): +def test_canonical_digest_keeps_field_order(): d1 = indexing._normalize_query_descriptor("(id >= 3) & (id < 6)", ["__self__"], ["a", "b"]) d2 = indexing._normalize_query_descriptor("(id >= 3) & (id < 6)", ["__self__"], ["b", "a"]) assert indexing._query_cache_digest(d1) != indexing._query_cache_digest(d2) @@ -1741,7 +1744,7 @@ def test_in_memory_array_hot_cache_hit(): # --------------------------------------------------------------------------- -def test_persistent_arrays_do_not_create_query_cache_artifacts(tmp_path): +def test_persistent_arrays_no_cache_artifacts(tmp_path): arr, urlpath = _make_persistent_array(tmp_path) _clear_caches() @@ -1777,7 +1780,7 @@ def test_persistent_cache_helpers_are_disabled(tmp_path): assert not Path(indexing._query_cache_payload_path(arr)).exists() -def test_store_cached_coords_for_persistent_array_uses_hot_cache_only(tmp_path): +def test_cached_coords_use_hot_cache_only(tmp_path): arr, _ = _make_persistent_array(tmp_path, n=8_000) _clear_caches() @@ -1920,7 +1923,7 @@ def test_ordered_query_indices_cached(tmp_path, monkeypatch): np.testing.assert_array_equal(result1, result2) -def test_ordered_query_cache_distinguishes_order_sequences(tmp_path): +def test_query_cache_distinguishes_orders(tmp_path): path = tmp_path / "ordered_sequences.b2nd" dtype = np.dtype([("a", np.int64), ("b", np.int64)]) data = np.array([(1, 2), (1, 1), (2, 1), (2, 2)], dtype=dtype) @@ -2254,7 +2257,7 @@ def test_inmem_indices_cache_entries_are_dropped_on_gc(): assert indexing._HOT_CACHE == {} -def test_ondisk_indices_path_no_cross_array_hot_cache_contamination(tmp_path): +def test_ondisk_no_cross_array_cache_mixing(tmp_path): dtype = np.dtype([("id", np.int64), ("val", np.float32)]) data1 = np.empty(1_000, dtype=dtype) data2 = np.empty(1_000, dtype=dtype) diff --git a/tests/ndarray/test_jit.py b/tests/ndarray/test_jit.py index df0057f3b..70db6fedb 100644 --- a/tests/ndarray/test_jit.py +++ b/tests/ndarray/test_jit.py @@ -179,7 +179,7 @@ def reduc_std_jit_cparams(a, b, c): assert d_jit.schunk.cparams.filters == [blosc2.Filter.BITSHUFFLE] + [blosc2.Filter.NOFILTER] * 5 -def test_jit_execution_tuning_kwarg_alone_keeps_numpy_return(): +def test_tuning_kwarg_alone_keeps_numpy_return(): # jit/jit_backend/fp_accuracy tune *how* an expression runs, not what # container the result comes back in -- they must not by themselves flip # the return type from NumPy to NDArray (unlike storage kwargs). @@ -194,7 +194,7 @@ def f(a, b): np.testing.assert_allclose(res, a * 2.0 + b) -def test_jit_execution_tuning_kwarg_with_storage_kwarg_still_returns_ndarray(): +def test_tuning_plus_storage_kwarg_gives_ndarray(): @blosc2.jit(jit=False, cparams=blosc2.CParams(clevel=2)) def f(a, b): return a * 2.0 + b @@ -207,7 +207,7 @@ def f(a, b): np.testing.assert_allclose(res[:], a * 2.0 + b) -def test_jit_numpy_return_with_storage_and_tuning_kwargs(): +def test_numpy_return_with_both_kwarg_kinds(): # A traced function whose return is already a NumPy array takes the # asarray() branch, which accepts storage kwargs only -- forwarding the # execution-tuning ones there raised instead of returning an NDArray. diff --git a/tests/ndarray/test_jit_dsl_dispatch.py b/tests/ndarray/test_jit_dsl_dispatch.py index 6a7ba8751..f9e421725 100644 --- a/tests/ndarray/test_jit_dsl_dispatch.py +++ b/tests/ndarray/test_jit_dsl_dispatch.py @@ -34,7 +34,7 @@ def _mandel_grid(): return cr, ci -def test_jit_control_flow_dispatches_to_dsl_and_matches_numpy(monkeypatch, capsys): +def test_control_flow_dispatches_to_dsl(monkeypatch, capsys): @blosc2.jit def mandel(cr, ci, max_iter): zr = 0.0 @@ -101,7 +101,7 @@ def elemwise(a, b): assert calls == [] # no control flow -> never routed through the DSL/lazyudf path -def test_jit_strict_true_on_elementwise_dsl_valid_function_uses_dsl(monkeypatch): +def test_strict_true_elementwise_uses_dsl(monkeypatch): calls = [] import blosc2.proxy as proxy_mod @@ -124,7 +124,7 @@ def elemwise(a, b): assert calls # dispatched through the DSL wrapper -def test_jit_strict_true_on_non_dsl_function_raises_at_decoration_time(): +def test_strict_true_non_dsl_raises_early(): with pytest.raises(Exception, match="axis"): @blosc2.jit(strict=True) @@ -145,7 +145,7 @@ def cf_func(a, b): np.testing.assert_allclose(res, a + b) -def test_jit_control_flow_on_python_scalar_flag_still_traces(): +def test_control_flow_scalar_flag_still_traces(): @blosc2.jit def scalar_flag(a, b, flag): if flag: @@ -177,7 +177,7 @@ def _kernel_src(a, b, n): return acc -def test_jit_dsl_route_out_numpy_c_contiguous_filled_in_place(): +def test_out_numpy_contiguous_filled_in_place(): a = np.arange(1000, dtype=np.float64) b = np.arange(1000, dtype=np.float64) * 0.5 out = np.empty(1000, dtype=np.float64) @@ -187,7 +187,7 @@ def test_jit_dsl_route_out_numpy_c_contiguous_filled_in_place(): np.testing.assert_allclose(out, (a + b) * 3) -def test_jit_dsl_route_out_numpy_non_contiguous_uses_copyto_fallback(): +def test_out_numpy_non_contiguous_uses_copyto(): a = np.arange(1000, dtype=np.float64) b = np.arange(1000, dtype=np.float64) * 0.5 out = np.empty(2000, dtype=np.float64)[::2] @@ -198,7 +198,7 @@ def test_jit_dsl_route_out_numpy_non_contiguous_uses_copyto_fallback(): np.testing.assert_allclose(out, (a + b) * 3) -def test_jit_dsl_route_out_mismatched_shape_or_dtype_raises_typeerror(): +def test_out_mismatched_shape_or_dtype_raises(): a = np.arange(1000, dtype=np.float64) b = np.arange(1000, dtype=np.float64) * 0.5 @@ -209,7 +209,7 @@ def test_jit_dsl_route_out_mismatched_shape_or_dtype_raises_typeerror(): blosc2.jit(out=np.empty(1000, dtype=np.float32))(_kernel_src)(a, b, 3) -def test_jit_dsl_route_ndarray_out_raises_not_implemented_mentioning_urlpath(): +def test_ndarray_out_raises_and_names_urlpath(): a = np.arange(1000, dtype=np.float64) b = np.arange(1000, dtype=np.float64) * 0.5 nd_out = blosc2.zeros((1000,), dtype=np.float64) @@ -228,7 +228,7 @@ def test_jit_dsl_route_compute_urlpath_persists_result(tmp_path): np.testing.assert_allclose(reopened[:], (a + b) * 3) -def test_jit_dsl_route_ndarray_operands_match_numpy_operands(): +def test_ndarray_operands_match_numpy(): a = np.arange(1000, dtype=np.float64) b = np.arange(1000, dtype=np.float64) * 0.5 na = blosc2.asarray(a) @@ -239,7 +239,7 @@ def test_jit_dsl_route_ndarray_operands_match_numpy_operands(): np.testing.assert_array_equal(res_numpy, res_ndarray) -def test_jit_dsl_route_execution_tuning_kwarg_alone_keeps_numpy_return(): +def test_tuning_kwarg_alone_keeps_numpy_return(): # Same rule as the tracing route: jit/jit_backend/fp_accuracy tune execution, # not the return container, so they must not force an NDArray on their own. jit_f = blosc2.jit(jit=False)(_kernel_src) @@ -250,7 +250,7 @@ def test_jit_dsl_route_execution_tuning_kwarg_alone_keeps_numpy_return(): np.testing.assert_allclose(res, (a + b) * 3) -def test_jit_dsl_route_execution_tuning_kwarg_with_storage_kwarg_still_returns_ndarray(): +def test_tuning_plus_storage_kwarg_gives_ndarray(): jit_f = blosc2.jit(jit=False, cparams=blosc2.CParams(clevel=2))(_kernel_src) a = np.arange(1000, dtype=np.float64) b = np.arange(1000, dtype=np.float64) * 0.5 diff --git a/tests/ndarray/test_lazyexpr.py b/tests/ndarray/test_lazyexpr.py index 5055c5ab0..0ae650a1f 100644 --- a/tests/ndarray/test_lazyexpr.py +++ b/tests/ndarray/test_lazyexpr.py @@ -1578,7 +1578,7 @@ def test_numpy_funcs(array_fixture, func): pytest.skip("NumPy version has no cumulative_sum function.") -def test_lazyexpr_string_scalar_keeps_miniexpr_fast_path(monkeypatch): +def test_string_scalar_keeps_miniexpr_path(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -1612,7 +1612,7 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr -def test_lazyexpr_unary_negative_literal_matches_subtraction(monkeypatch): +def test_unary_negative_literal_matches_sub(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") @@ -1647,7 +1647,7 @@ def wrapped_set_pref_expr(self, expression, inputs, fp_accuracy, aux_reduc=None, lazyexpr_mod.try_miniexpr = old_try_miniexpr -def test_lazyexpr_miniexpr_failure_falls_back_by_default(monkeypatch): +def test_miniexpr_failure_falls_back_default(monkeypatch): import importlib lazyexpr_mod = importlib.import_module("blosc2.lazyexpr") diff --git a/tests/ndarray/test_linalg.py b/tests/ndarray/test_linalg.py index fec3077a0..d18a6901e 100644 --- a/tests/ndarray/test_linalg.py +++ b/tests/ndarray/test_linalg.py @@ -162,7 +162,7 @@ def test_matmul_uses_fast_path_for_supported_2d(monkeypatch, dtype): @pytest.mark.parametrize("dtype", [np.float32, np.float64]) -def test_matmul_uses_fast_path_with_multiple_inner_blocks(monkeypatch, dtype): +def test_matmul_fast_path_many_inner_blocks(monkeypatch, dtype): old_flag = utils_mod.try_miniexpr calls = _set_pref_matmul_call_recorder(monkeypatch) try: @@ -257,7 +257,7 @@ def test_matmul_falls_back_for_dtype_mismatch(monkeypatch): _toggle_miniexpr(old_flag) -def test_matmul_fast_path_limits_blas_threads_for_cblas(monkeypatch): +def test_matmul_limits_blas_threads_for_cblas(monkeypatch): old_flag = utils_mod.try_miniexpr calls = [] @@ -294,7 +294,7 @@ def __exit__(self, exc_type, exc, tb): _toggle_miniexpr(old_flag) -def test_matmul_fast_path_skips_blas_thread_limits_above_block_threshold(monkeypatch): +def test_matmul_keeps_blas_threads_over_limit(monkeypatch): old_flag = utils_mod.try_miniexpr def unexpected_threadpool_limits(*args, **kwargs): @@ -321,7 +321,7 @@ def unexpected_threadpool_limits(*args, **kwargs): _toggle_miniexpr(old_flag) -def test_matmul_fast_path_skips_blas_thread_limits_on_darwin(monkeypatch): +def test_matmul_keeps_blas_threads_on_darwin(monkeypatch): old_flag = utils_mod.try_miniexpr def unexpected_threadpool_limits(*args, **kwargs): @@ -347,7 +347,7 @@ def unexpected_threadpool_limits(*args, **kwargs): _toggle_miniexpr(old_flag) -def test_matmul_fast_path_skips_blas_thread_limits_for_non_cblas(monkeypatch): +def test_matmul_keeps_blas_threads_non_cblas(monkeypatch): old_flag = utils_mod.try_miniexpr def unexpected_threadpool_limits(*args, **kwargs): @@ -372,7 +372,8 @@ def unexpected_threadpool_limits(*args, **kwargs): _toggle_miniexpr(old_flag) -def test_matmul_fast_path_skips_blas_thread_limits_when_threadpoolctl_missing(monkeypatch): +def test_matmul_keeps_blas_threads_no_tpctl(monkeypatch): + """Without threadpoolctl installed, the BLAS thread limit is left alone.""" old_flag = utils_mod.try_miniexpr monkeypatch.setattr(blosc2_linalg, "threadpool_limits", None) monkeypatch.setattr(blosc2.blosc2_ext, "get_selected_matmul_block_backend", lambda: "cblas") diff --git a/tests/ndarray/test_ndarray.py b/tests/ndarray/test_ndarray.py index 672d4b545..270b9a7e0 100644 --- a/tests/ndarray/test_ndarray.py +++ b/tests/ndarray/test_ndarray.py @@ -103,7 +103,7 @@ def test_asarray(a): np.testing.assert_allclose(a, b[:]) -def test_asarray_ndarray_persists_copy_when_urlpath_requested(tmp_path): +def test_asarray_persists_copy_with_urlpath(tmp_path): array = blosc2.asarray(np.arange(10, dtype=np.int64), chunks=(5,), blocks=(2,)) path = tmp_path / "persisted_copy.b2nd" @@ -115,7 +115,8 @@ def test_asarray_ndarray_persists_copy_when_urlpath_requested(tmp_path): np.testing.assert_array_equal(persisted[:], array[:]) -def test_asarray_ndarray_copies_for_dtype_changes_and_rejects_copy_false(tmp_path): +def test_asarray_dtype_change_copies_or_raises(tmp_path): + """A dtype change copies; asking for copy=False with one is an error.""" array = blosc2.asarray(np.arange(10, dtype=np.int64), chunks=(5,), blocks=(2,)) cast = blosc2.asarray(array, dtype=np.float32) @@ -159,7 +160,7 @@ def test_array_copy_false_rejects_required_copy(): blosc2.array(a, dtype=np.float64, copy=False) -def test_array_copy_none_matches_asarray_for_compatible_ndarray(): +def test_array_copy_none_matches_asarray(): a = blosc2.asarray([1, 2, 3]) b = blosc2.array(a, copy=None) diff --git a/tests/ndarray/test_proxy.py b/tests/ndarray/test_proxy.py index d65e37a1f..17719b5dd 100644 --- a/tests/ndarray/test_proxy.py +++ b/tests/ndarray/test_proxy.py @@ -107,7 +107,7 @@ def test_open(urlpath, shape, chunks, blocks, slices, dtype): blosc2.remove_urlpath(proxy_urlpath) -def test_open_readonly_proxy_keeps_cache_and_source_readonly(tmp_path): +def test_readonly_proxy_keeps_both_readonly(tmp_path): source_path = tmp_path / "source.b2nd" proxy_path = tmp_path / "proxy.b2nd" data = np.arange(120, dtype=np.int32).reshape(12, 10) diff --git a/tests/ndarray/test_slice.py b/tests/ndarray/test_slice.py index 6a0e690db..3234df718 100644 --- a/tests/ndarray/test_slice.py +++ b/tests/ndarray/test_slice.py @@ -27,7 +27,7 @@ def test_detect_aligned_chunks_exact_multiple_shape(): assert detect_aligned_chunks((slice(5, 10), slice(0, 10)), (10, 20), (5, 10)) == [2] -def test_detect_aligned_chunks_non_exact_multiple_shape(): +def test_aligned_chunks_non_exact_multiple(): # The bug repro: dim 1 (100_003) isn't a multiple of its chunk (40_000), # so its true chunk count is 3, not 100_003 // 40_000 == 2. Before the # fix this returned [2] (row 0, col chunk 2) instead of the correct [3] @@ -35,14 +35,14 @@ def test_detect_aligned_chunks_non_exact_multiple_shape(): assert detect_aligned_chunks((slice(1, 2), slice(0, 40_000)), (2, 100_003), (1, 40_000)) == [3] -def test_detect_aligned_chunks_unaligned_slice_returns_empty(): +def test_aligned_chunks_unaligned_gives_empty(): # A slice boundary that isn't a chunk multiple must short-circuit to [], # regardless of the n_chunks bug (this check runs before n_chunks is # even computed). assert detect_aligned_chunks((slice(1, 2), slice(0, 40_001)), (2, 100_003), (1, 40_000)) == [] -def test_detect_aligned_chunks_middle_dim_non_exact_multiple(): +def test_aligned_chunks_middle_dim_non_exact(): # 3D, non-exact-multiple dim in the *middle* (not last) position, offset # in the first dim -- pins that the fix's multiplier chain is right in # general, not just for the 2D case (last dim == only non-first dim) @@ -51,7 +51,7 @@ def test_detect_aligned_chunks_middle_dim_non_exact_multiple(): assert detect_aligned_chunks(key, (4, 7, 5), (2, 3, 5)) == [3] -def test_detect_aligned_chunks_multiple_non_exact_multiple_dims(): +def test_aligned_chunks_several_non_exact_dims(): # Both non-first dims are non-exact-multiple at once. key = (slice(2, 4), slice(0, 3), slice(0, 4)) assert detect_aligned_chunks(key, (4, 7, 11), (2, 3, 4)) == [9] @@ -65,13 +65,13 @@ def test_detect_aligned_chunks_consecutive_true(): assert detect_aligned_chunks(key, (10, 20), (5, 10), consecutive=True) == [0, 1, 2, 3] -def test_detect_aligned_chunks_consecutive_true_not_consecutive(): +def test_aligned_chunks_consecutive_flag_false(): # Same grid, a region whose chunks are NOT consecutive in flat order. key = (slice(0, 5), slice(0, 10)) assert detect_aligned_chunks(key, (10, 30), (5, 10), consecutive=True) == [0] -def test_detect_aligned_chunks_consecutive_true_non_exact_multiple_shape(): +def test_aligned_chunks_consecutive_non_exact(): # The bug pattern (non-exact-multiple trailing dim) under # consecutive=True: before the fix, the corrupted flat indices could # come out consecutive when they shouldn't (or vice versa), since the diff --git a/tests/test_b2view_model.py b/tests/test_b2view_model.py index 03611b693..582f26340 100644 --- a/tests/test_b2view_model.py +++ b/tests/test_b2view_model.py @@ -116,7 +116,7 @@ def test_preview_array_2d_returns_grid_preview(): np.testing.assert_array_equal(preview["data"]["4"], np.array([10, 16, 22])) -def test_store_browser_uses_grid_preview_for_2d_ndarray(tmp_path): +def test_browser_grid_preview_for_2d_ndarray(tmp_path): path = tmp_path / "bundle.b2z" with blosc2.TreeStore(str(path), mode="w") as store: store["/arr"] = np.arange(30).reshape(5, 6) @@ -154,7 +154,7 @@ def test_ctable_preview_buffer_reuses_loaded_rows(tmp_path): np.testing.assert_array_equal(page1["data"]["x"], np.arange(5, 10)) -def test_preview_ctable_skips_expensive_nested_columns_by_default(): +def test_preview_skips_expensive_nested_cols(): class Table: def __init__(self): self.col_names = ["path"] @@ -289,7 +289,7 @@ def __getitem__(self, name): assert preview["data"]["path"][1] == [{"x": 2}, {"x": 3}] -def test_ctable_preview_header_uses_column_names_without_dtype_labels(): +def test_preview_header_omits_dtype_labels(): preview = { "start": 0, "stop": 1, diff --git a/tests/test_batch_array.py b/tests/test_batch_array.py index 059d1599e..49981739d 100644 --- a/tests/test_batch_array.py +++ b/tests/test_batch_array.py @@ -143,7 +143,7 @@ def test_batcharray_arrow_ipc_roundtrip(): blosc2.remove_urlpath(urlpath) -def test_batcharray_inferred_layout_preserves_user_vlmeta(): +def test_batcharray_layout_keeps_user_vlmeta(): barray = blosc2.BatchArray() barray.vlmeta["user"] = {"x": 1} @@ -152,7 +152,7 @@ def test_batcharray_inferred_layout_preserves_user_vlmeta(): assert barray.vlmeta["user"] == {"x": 1} -def test_batcharray_arrow_layout_persistence_preserves_user_vlmeta(): +def test_batcharray_arrow_layout_keeps_vlmeta(): pa = pytest.importorskip("pyarrow") barray = blosc2.BatchArray(serializer="arrow") @@ -232,7 +232,7 @@ def fail_decode(*args, **kwargs): assert "items per batch: mean=" in items["nbatches"] -def test_batcharray_info_reports_exact_block_stats_from_lazy_chunks(): +def test_batcharray_info_block_stats_from_lazy(): barray = blosc2.BatchArray(items_per_block=2) barray.extend([[1, 2, 3, 4, 5], [6, 7], [8]]) @@ -240,7 +240,7 @@ def test_batcharray_info_reports_exact_block_stats_from_lazy_chunks(): assert items["nblocks"] == "5 (items per block: mean=1.60, max=2, min=1)" -def test_batcharray_pop_keeps_batch_lengths_metadata_in_sync(): +def test_batcharray_pop_keeps_lengths_in_sync(): barray = blosc2.BatchArray(items_per_block=2) barray.extend([[1, 2, 3], [4, 5], [6]]) @@ -253,7 +253,7 @@ def test_batcharray_pop_keeps_batch_lengths_metadata_in_sync(): assert items["nbatches"].startswith("2 (items per batch: mean=2.00") -def test_batcharray_clear_keeps_empty_store_vlmeta_readable(): +def test_batcharray_clear_keeps_vlmeta_ok(): urlpath = "test_batcharray_clear_empty_vlmeta.b2b" blosc2.remove_urlpath(urlpath) @@ -269,7 +269,7 @@ def test_batcharray_clear_keeps_empty_store_vlmeta_readable(): blosc2.remove_urlpath(urlpath) -def test_batcharray_delete_last_keeps_empty_store_vlmeta_readable(): +def test_batcharray_delete_last_keeps_vlmeta(): urlpath = "test_batcharray_delete_last_empty_vlmeta.b2b" blosc2.remove_urlpath(urlpath) @@ -360,7 +360,7 @@ def test_batcharray_iter_items(): assert list(barray.iter_items()) == [1, 2, 3, 4, 5, 6] -def test_batcharray_respects_explicit_use_dict_and_non_zstd(): +def test_batcharray_use_dict_and_non_zstd(): barray = blosc2.BatchArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) assert barray.cparams.codec == blosc2.Codec.LZ4 assert barray.cparams.use_dict is False @@ -380,36 +380,26 @@ def test_batcharray_respects_explicit_use_dict_and_non_zstd(): assert barray.cparams.use_dict is False -def test_batcharray_guess_items_per_block_uses_1mib_budget_for_low_clevel(monkeypatch): - # Budgets are fixed; detected cache sizes must not influence the layout. +@pytest.mark.parametrize( + ("clevel", "payloads", "expected"), + [ + # 1 MiB budget: three 300 KiB payloads fit, a fourth would exceed it + (3, [300 * 1024] * 4, 3), + # 8 MiB budget: a single 5 MiB payload fits, a second would exceed it + (5, [5 * 2**20] * 4, 1), + # 16 MiB budget: two 6 MiB payloads fit, a third would exceed it + (7, [6 * 2**20] * 4, 2), + # clevel 9 takes the whole batch, whatever the payload sizes + (9, [100] * 4, 4), + ], + ids=["1mib-low", "8mib-default", "16mib-high", "full-batch"], +) +def test_batcharray_blocksize_budget(monkeypatch, clevel, payloads, expected): + """The per-clevel budget picks the block size, not the detected caches.""" monkeypatch.setitem(blosc2.cpu_info, "l1_data_cache_size", 100) monkeypatch.setitem(blosc2.cpu_info, "l2_cache_size", 1000) - barray = blosc2.BatchArray(cparams={"clevel": 3}) - # 1 MiB budget: three 300 KiB payloads fit, a fourth would exceed it - assert barray._guess_blocksize([300 * 1024] * 4) == 3 - - -def test_batcharray_guess_items_per_block_uses_8mib_budget_for_default_clevel(monkeypatch): - monkeypatch.setitem(blosc2.cpu_info, "l1_data_cache_size", 100) - monkeypatch.setitem(blosc2.cpu_info, "l2_cache_size", 150) - barray = blosc2.BatchArray(cparams={"clevel": 5}) - # 8 MiB budget: a single 5 MiB payload fits, a second would exceed it - assert barray._guess_blocksize([5 * 2**20] * 4) == 1 - - -def test_batcharray_guess_items_per_block_uses_16mib_budget_for_high_clevel(monkeypatch): - monkeypatch.setitem(blosc2.cpu_info, "l1_data_cache_size", 100) - monkeypatch.setitem(blosc2.cpu_info, "l2_cache_size", 150) - barray = blosc2.BatchArray(cparams={"clevel": 7}) - # 16 MiB budget: two 6 MiB payloads fit, a third would exceed it - assert barray._guess_blocksize([6 * 2**20] * 4) == 2 - - -def test_batcharray_guess_items_per_block_uses_full_batch_for_clevel_9(monkeypatch): - monkeypatch.setitem(blosc2.cpu_info, "l1_data_cache_size", 1) - monkeypatch.setitem(blosc2.cpu_info, "l2_cache_size", 1) - barray = blosc2.BatchArray(cparams={"clevel": 9}) - assert barray._guess_blocksize([100, 100, 100, 100]) == 4 + barray = blosc2.BatchArray(cparams={"clevel": clevel}) + assert barray._guess_blocksize(payloads) == expected def test_vlcompress_small_blocks_roundtrip(): @@ -636,7 +626,7 @@ def test_batcharray_copy(): blosc2.remove_urlpath(copy_path) -def test_batcharray_copy_with_storage_preserves_user_metadata(): +def test_batcharray_copy_keeps_user_metadata(): urlpath = "test_batcharray_copy_storage.b2b" copy_path = "test_batcharray_copy_storage_out.b2b" blosc2.remove_urlpath(urlpath) diff --git a/tests/test_dict_store.py b/tests/test_dict_store.py index 01aeb018f..92bd8bb15 100644 --- a/tests/test_dict_store.py +++ b/tests/test_dict_store.py @@ -117,7 +117,7 @@ def test_to_b2z_and_reopen(populated_dict_store): assert np.all(dstore_read["/nodeB"][:] == np.arange(6)) -def test_extensionless_dict_store_defaults_to_directory(tmp_path): +def test_extensionless_store_is_a_directory(tmp_path): path = tmp_path / "test_dstore_extless" with DictStore(str(path), mode="w") as dstore: @@ -421,7 +421,7 @@ def test_external_objectarray_file_and_reopen(tmp_path): @pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) -def test_metadata_discovery_reopens_renamed_external_ndarray(storage_type, tmp_path): +def test_discovery_reopens_renamed_ndarray(storage_type, tmp_path): path = tmp_path / f"test_renamed_ndarray.{storage_type}" ext_path = tmp_path / "renamed_array_source.b2nd" @@ -445,7 +445,7 @@ def test_metadata_discovery_reopens_renamed_external_ndarray(storage_type, tmp_p @pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) -def test_metadata_discovery_reopens_renamed_external_objectarray(storage_type, tmp_path): +def test_discovery_reopens_renamed_objectarray(storage_type, tmp_path): path = tmp_path / f"test_renamed_objectarray.{storage_type}" ext_path = tmp_path / "renamed_objectarray_source.b2frame" values = ["alpha", {"nested": True}, None, (1, 2, 3)] diff --git a/tests/test_group_reduce.py b/tests/test_group_reduce.py index 5e0478aea..1f803bfa5 100644 --- a/tests/test_group_reduce.py +++ b/tests/test_group_reduce.py @@ -18,7 +18,7 @@ def test_group_reduce_size_and_sum_integer_keys(): np.testing.assert_array_equal(sums, np.array([4, 90])) -def test_group_reduce_integer_keys_float_aggs_with_nan_values(): +def test_int_keys_float_aggs_with_nan_values(): keys = np.array([0, 1, 0, 1, 2], dtype=np.uint16) values = np.array([1.0, np.nan, 3.0, np.nan, 10.0]) @@ -40,7 +40,7 @@ def test_group_reduce_integer_keys_float_aggs_with_nan_values(): assert maxs[2] == 10.0 -def test_group_reduce_arbitrary_float_keys_and_nan_key_group(): +def test_float_keys_and_nan_key_group(): keys = np.array([0.5, np.nan, 0.5, -0.0, 0.0, np.nan]) values = np.array([1.0, 2.0, 3.0, 10.0, 20.0, 5.0]) @@ -54,7 +54,7 @@ def test_group_reduce_arbitrary_float_keys_and_nan_key_group(): assert sums[2] == 7.0 -def test_group_reduce_object_keys_sort_none_first_nan_last(): +def test_object_keys_sort_none_first_nan_last(): keys = np.array([np.nan, None, "b", "a", np.nan, None], dtype=object) groups, sizes = blosc2.group_reduce(keys, op="size", sort=True, dropna=False) diff --git a/tests/test_list_array.py b/tests/test_list_array.py index 2aba378b3..8342ff3d6 100644 --- a/tests/test_list_array.py +++ b/tests/test_list_array.py @@ -50,7 +50,7 @@ def test_listarray_append_extend_and_replace(storage, tmp_path): assert restored[:] == reopened[:] -def test_listarray_batch_pending_rows_visible_before_flush(): +def test_listarray_pending_rows_visible(): arr = blosc2.ListArray(item_spec=blosc2.int32(), storage="batch", batch_rows=4) arr.append([1, 2]) arr.append([]) @@ -86,7 +86,7 @@ def test_listarray_arrow_roundtrip(): assert arr.to_arrow().to_pylist() == [["a"], None, ["b", "c"]] -def test_listarray_extend_validate_false_preserves_none(): +def test_listarray_extend_no_validate_keeps_none(): arr = blosc2.ListArray(item_spec=blosc2.int32(), nullable=True, storage="batch", batch_rows=2) arr.extend([[1], None, [2, 3]], validate=False) assert arr[:] == [[1], None, [2, 3]] @@ -139,7 +139,7 @@ def test_listarray_copy_fast_path_empty(): assert dst[:] == [] -def test_listarray_copy_cparams_override_uses_slow_path(): +def test_listarray_copy_cparams_slow_path(): # Supplying cparams must bypass chunk_copy and still produce correct data. src = _make_batch_array() dst = src.copy(cparams={"codec": blosc2.Codec.LZ4, "clevel": 1}) diff --git a/tests/test_locking.py b/tests/test_locking.py index 0ad4de0a4..f8848dd09 100644 --- a/tests/test_locking.py +++ b/tests/test_locking.py @@ -685,7 +685,7 @@ def test_cross_process_multiwriter_ndarray_append(tmp_path): blosc2.remove_urlpath(urlpath) -def test_cross_process_multiwriter_ndarray_append_sparse_nonaligned(tmp_path): +def test_multiwriter_append_sparse_nonaligned(tmp_path): # Same bug class as test_cross_process_multiwriter_ndarray_append, but # on the two physical layouts that test didn't touch: sparse storage # (contiguous=False, each chunk its own file, a different rewrite path diff --git a/tests/test_objectarray.py b/tests/test_objectarray.py index 5006b67c4..f86a2c31b 100644 --- a/tests/test_objectarray.py +++ b/tests/test_objectarray.py @@ -261,7 +261,7 @@ def test_objectarray_msgpack_supports_lazyexpr(tmp_path): np.testing.assert_array_equal(restored[:], expected) -def test_objectarray_msgpack_supports_lazyudf_dslkernel(tmp_path): +def test_msgpack_supports_lazyudf_dslkernel(tmp_path): udf, expected = _make_persistent_lazyudf(tmp_path) oarr = blosc2.ObjectArray() @@ -272,7 +272,7 @@ def test_objectarray_msgpack_supports_lazyudf_dslkernel(tmp_path): np.testing.assert_allclose(restored[:], expected) -def test_objectarray_msgpack_rejects_lazyexpr_with_in_memory_operands(): +def test_msgpack_rejects_in_memory_lazyexpr(): expr = _make_in_memory_lazyexpr() oarr = blosc2.ObjectArray() @@ -280,7 +280,7 @@ def test_objectarray_msgpack_rejects_lazyexpr_with_in_memory_operands(): oarr.append(expr) -def test_objectarray_msgpack_rejects_plain_python_lazyudf(tmp_path): +def test_msgpack_rejects_plain_python_lazyudf(tmp_path): udf = _make_persistent_python_lazyudf(tmp_path) oarr = blosc2.ObjectArray() @@ -337,7 +337,7 @@ def test_objectarray_zstd_uses_dict_by_default(): assert oarr.cparams.use_dict is True -def test_objectarray_respects_explicit_use_dict_and_non_zstd(): +def test_objectarray_use_dict_and_non_zstd(): oarr = blosc2.ObjectArray(cparams={"codec": blosc2.Codec.LZ4, "clevel": 5}) assert oarr.cparams.codec == blosc2.Codec.LZ4 assert oarr.cparams.use_dict is False @@ -537,7 +537,7 @@ def test_objectarray_delete_negative_step_slice(): assert len(oarr2) == 0 -def test_varlen_scalar_column_comparisons_are_elementwise(): +def test_varlen_scalar_cmp_is_elementwise(): """``column == value`` must not fall through to object identity.""" from dataclasses import make_dataclass diff --git a/tests/test_pandas_udf_engine.py b/tests/test_pandas_udf_engine.py index 7fa10d4df..92f0149df 100644 --- a/tests/test_pandas_udf_engine.py +++ b/tests/test_pandas_udf_engine.py @@ -181,7 +181,7 @@ def test_apply_object_dtype_raises_clear_error(self): with pytest.raises(ValueError, match="numeric dtype"): df.apply(lambda x: x + 1, engine=blosc2.jit) - def test_apply_axis1_row_subscript_idiom_matches_default_engine(self): + def test_axis1_subscript_matches_default_engine(self): def add_people(row): return row["max_people"] + row["max_children"] @@ -190,7 +190,7 @@ def add_people(row): result = df.apply(add_people, engine=blosc2.jit, axis=1) pd.testing.assert_series_equal(result, expected) - def test_apply_axis1_row_subscript_args_kwargs_forwarded(self): + def test_axis1_subscript_args_kwargs_forwarded(self): def combine(row, num1, num2=0): return row["a"] + row["b"] + num1 + num2 @@ -199,7 +199,7 @@ def combine(row, num1, num2=0): result = df.apply(combine, engine=blosc2.jit, axis=1, args=(10,), num2=100) pd.testing.assert_series_equal(result, expected) - def test_apply_axis1_row_subscript_preserves_column_dtype(self): + def test_axis1_subscript_keeps_column_dtype(self): # a mixed-dtype frame would be upcast by DataFrame.values; the row # proxy must extract columns from the original frame instead. def add(row): @@ -209,7 +209,7 @@ def add(row): result = df.apply(add, engine=blosc2.jit, axis=1) np.testing.assert_allclose(result.to_numpy(), [1.5, 2.5, 3.5]) - def test_apply_axis1_row_subscript_with_loop_raises_clear_error(self): + def test_axis1_subscript_with_loop_raises(self): def kepler_row(row): m, ecc = row["m"], row["ecc"] e = m + ecc * np.sin(m) @@ -222,7 +222,7 @@ def kepler_row(row): with pytest.raises(TypeError, match="for/while loop"): df.apply(kepler_row, engine=blosc2.jit, axis=1) - def test_apply_axis1_row_subscript_duplicate_column_raises(self): + def test_axis1_subscript_duplicate_col_raises(self): def add(row): return row["a"] + 1 @@ -230,7 +230,7 @@ def add(row): with pytest.raises(KeyError, match="duplicated"): df.apply(add, engine=blosc2.jit, axis=1) - def test_apply_axis1_row_subscript_attribute_access_raises(self): + def test_axis1_subscript_attr_access_raises(self): def bad(row): return row["a"] + row.b @@ -238,7 +238,7 @@ def bad(row): with pytest.raises(AttributeError, match="row\\['b'\\]"): df.apply(bad, engine=blosc2.jit, axis=1) - def test_apply_axis1_row_subscript_unvectorizable_column_raises(self): + def test_axis1_subscript_unvectorizable_raises(self): # String columns are supported now, so the per-column check in # `_PandasRowProxy` is what still rejects a dtype the engine cannot # vectorize at all. @@ -249,7 +249,7 @@ def bad(row): with pytest.raises(ValueError, match="cannot vectorize"): df.apply(bad, engine=blosc2.jit, axis=1) - def test_apply_axis1_positional_idiom_still_uses_per_row_loop(self): + def test_axis1_positional_uses_per_row_loop(self): # No `row["..."]` subscript: falls back to the historical per-row # loop, unaffected by the row-proxy dispatch added for the subscript # idiom above. @@ -258,7 +258,7 @@ def test_apply_axis1_positional_idiom_still_uses_per_row_loop(self): result = df.apply(lambda row: row * 2, engine=blosc2.jit, axis=1) pd.testing.assert_frame_equal(result, expected) - def test_apply_already_jitted_function_is_not_decorated_twice(self): + def test_apply_jitted_func_not_decorated_twice(self): # Decorating and passing engine= both request the same thing. Applying # the decorator a second time used to wrap the array in a SimpleProxy # before the inner DSL kernel saw it, which then failed asking for @@ -277,7 +277,7 @@ def branch(col): result = df.apply(func, engine=blosc2.jit) pd.testing.assert_frame_equal(result, expected) - def test_map_already_jitted_function_is_not_decorated_twice(self): + def test_map_jitted_func_not_decorated_twice(self): def branch(col): if col >= 0: out = col * 2.0 diff --git a/tests/test_proxy_schunk.py b/tests/test_proxy_schunk.py index dcd793ec5..3164e1d6e 100644 --- a/tests/test_proxy_schunk.py +++ b/tests/test_proxy_schunk.py @@ -77,7 +77,7 @@ def test_open(urlpath, chunksize, nchunks): blosc2.remove_urlpath(proxy_urlpath) -def test_open_readonly_proxy_keeps_schunk_cache_and_source_readonly(tmp_path): +def test_readonly_proxy_keeps_both_readonly(tmp_path): source_path = tmp_path / "source.b2frame" proxy_path = tmp_path / "proxy.b2frame" data = np.arange(200, dtype="int32") diff --git a/tests/test_python_blosc.py b/tests/test_python_blosc.py index 8d0b3d149..abcc631b2 100644 --- a/tests/test_python_blosc.py +++ b/tests/test_python_blosc.py @@ -184,7 +184,7 @@ def test_unpack_array_with_from_py27_exceptions(self): with pytest.raises(UnicodeDecodeError): blosc2.unpack_array(self.PY_27_INPUT) - def test_unpack_array_with_unicode_characters_from_py27(self): + def test_unpack_array_unicode_from_py27(self): import numpy as np out_array = np.array(["å", "ç", "ø", "π", "˚"]) diff --git a/tests/test_random.py b/tests/test_random.py index b5fdf9c0d..fc8902230 100644 --- a/tests/test_random.py +++ b/tests/test_random.py @@ -216,7 +216,7 @@ def test_choice_2d_a_not_implemented(): [(m, a, k) for m, (a, k) in _VECTOR_DIST_CASES.items()], ids=_VECTOR_DIST_CASES.keys(), ) -def test_vector_distribution_shape_reproducible_and_finite(method, args, k): +def test_vector_dist_reproducible_and_finite(method, args, k): def draw(): rng = getattr(blosc2.random.default_rng(0), method) return rng(*args, shape=(40,)) @@ -249,7 +249,7 @@ def test_vector_dist_numpy_integer_shape(): assert a.shape == (5, 3) -def test_permutation_int_is_a_permutation_and_reproducible(): +def test_permutation_int_is_valid_and_stable(): a = blosc2.random.default_rng(0).permutation(10) b = blosc2.random.default_rng(0).permutation(10) np.testing.assert_array_equal(a[:], b[:]) diff --git a/tests/test_tree_store.py b/tests/test_tree_store.py index b89d18e51..b36ff81c6 100644 --- a/tests/test_tree_store.py +++ b/tests/test_tree_store.py @@ -690,7 +690,7 @@ def test_external_batcharray_support(tmp_path): @pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) -def test_metadata_discovery_reopens_renamed_batcharray_leaf(storage_type, tmp_path): +def test_discovery_reopens_renamed_batcharray(storage_type, tmp_path): store_path = tmp_path / f"test_batcharray_renamed.{storage_type}" with TreeStore(str(store_path), mode="w", threshold=0) as tstore: @@ -1075,7 +1075,7 @@ def test_to_b2d_from_readonly_b2z(tmp_path): assert tstore.vlmeta["description"] == "tree metadata" -def test_extensionless_tree_store_defaults_to_directory(tmp_path): +def test_extensionless_store_is_a_directory(tmp_path): path = tmp_path / "test_tstore_extless" with TreeStore(str(path), mode="w") as tstore: @@ -1456,7 +1456,7 @@ def test_ctable_values_collapses_object_roots(tmp_path, storage_type): @pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) -def test_ctable_delete_parent_subtree_removes_nested_object(tmp_path, storage_type): +def test_delete_parent_subtree_removes_nested(tmp_path, storage_type): """Deleting a normal subtree also deletes nested object roots and physical leaves.""" path = str(tmp_path / f"bundle.{storage_type}") with blosc2.TreeStore(path, mode="w") as ts: @@ -1492,7 +1492,7 @@ def test_ctable_inline_index_roundtrip(tmp_path, storage_type): @pytest.mark.parametrize("storage_type", ["b2d", "b2z"]) -def test_ctable_registry_missing_fallback_hides_and_protects_internals(tmp_path, storage_type): +def test_registry_fallback_hides_and_protects(tmp_path, storage_type): """Physical CTable manifests are enough to detect object roots if registry is missing.""" path = str(tmp_path / f"bundle.{storage_type}") with blosc2.TreeStore(path, mode="w") as ts: From cb2862437ec4ad6b5619d1f3c22ab5251a776506 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 20:15:49 +0200 Subject: [PATCH 75/86] Rewrite the string-type decision path around the real trade-off The section led with dictionary and described string() as "short codes of near-uniform length", which undersells the fastest of the four types: at max_length=32 it reads in 23 ms against utf8's 35 and filters in 104 ms against 165, on 500k rows. Recast it around the question that actually decides the choice -- is the length bounded, and is the bound small -- and give 32 as the threshold with the measurements behind it, including where the advantage decays (overtaken between 64 and 128, by which point fixed-width is using 60x the memory). 32 is also the width a bare `str` annotation already picks, so the default is now presented as the fast path rather than a compatibility leftover. Two things the old text did not say and a reader needs: the per-row cost is paid on every read rather than only on disk, which is why the width matters at all; and the compressed sizes of string() and utf8() are within ~30% on low-entropy text but ~7x apart on high-entropy values, so neither figure generalises. Also states what over-running max_length actually does -- raises on write, never truncates -- since the availability risk, not data loss, is the reason to prefer utf8 when the bound is a guess. Co-Authored-By: Claude Opus 5 --- doc/reference/ctable.rst | 80 +++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/doc/reference/ctable.rst b/doc/reference/ctable.rst index cb5058272..cbd3341ff 100644 --- a/doc/reference/ctable.rst +++ b/doc/reference/ctable.rst @@ -983,15 +983,74 @@ Text & binary Choosing a string column type ----------------------------- -CTable offers four ways to store strings. As a quick decision path: +CTable offers four ways to store strings. The question that decides it is +**do you know a bound on the length, and is it small?** Fixed-width storage +is the fastest of the four whenever you can name that bound; the +variable-length types exist for when you cannot. * Low-cardinality strings (categories, enumerations, repeated labels): - use :func:`dictionary` — repeated values are stored once as integer codes. -* Everything else (names, free text, high-cardinality values): - use :func:`utf8` — the recommended default for variable-length text. -* Short codes of near-uniform length: use :class:`string` (fixed-width). -* NumPy < 2.0, or nullable columns where any string value can legally occur - (native ``None`` nulls, no sentinel): use :func:`vlstring`. + use :func:`dictionary` — repeated values are stored once as integer codes, + and it has the fastest ``group_by`` of any flavour. +* **Bounded length, up to about 32 characters** (identifiers, ISO dates, + currency or country codes, hashes, SKUs): use :class:`string`. It is the + fastest option to read and to filter, every index kind works on it, and + string-returning expressions run on it natively. +* **Unbounded or long** (names, addresses, free text, URLs, log lines, user + content): use :func:`utf8`. There is no width to declare and rows cost + what they weigh. +* Nullable columns where *any* string value can legally occur, or NumPy + < 2.0: use :func:`vlstring` / :func:`vlbytes`. These are the only types + with a native ``None`` null that cannot collide with a real value, and + :func:`vlbytes` is the only one that stores arbitrary (non-UTF-8) bytes. + +Why 32, and what happens past it. A fixed-width column costs +4 × ``max_length`` bytes per row **every time it is read**, not merely on +disk — compression hides this from the file size but not from memory. So the +advantage decays as the declared width grows. Measured on 500 000 rows of +mean length 14: + +.. list-table:: + :header-rows: 1 + :stub-columns: 1 + + * - + - in-memory size + - full read + - ``where(startswith(...))`` + * - ``string(max_length=32)`` + - 64 MB + - **23 ms** + - **104 ms** + * - ``string(max_length=64)`` + - 128 MB + - 30 ms + - 124 ms + * - ``string(max_length=128)`` + - 256 MB + - 40 ms + - 154 ms + * - :func:`utf8` + - **4 MB** + - 35 ms + - 165 ms + +Fixed-width wins outright at 32, still leads at 48, and is overtaken between +64 and 128 — by which point it is also using 60× the memory. Treat 32 as a +comfortable recommendation rather than a cliff: if your data is bounded at 40, +:class:`string` is still the better choice. + +Declaring ``max_length`` too small is safe in the sense that matters: a value +that does not fit raises ``ValueError`` on write rather than being silently +truncated. It is not safe for *availability* — the write fails, and a column +whose bound you guessed from a sample can start rejecting rows in production. +When the bound is a guess rather than a fact, that is the signal to use +:func:`utf8`. + +The compressed sizes of :class:`string` and :func:`utf8` are much closer than +the in-memory sizes, but by how much depends entirely on the data: the UCS-4 +padding compresses away almost completely on low-entropy text (within ~30 % +of utf8) and much less well on high-entropy values (~7× larger was measured +above). Do not plan storage on either figure without measuring your own data. .. list-table:: :header-rows: 1 @@ -1091,9 +1150,10 @@ CTable offers four ways to store strings. As a quick decision path: :ref:`ComputingUtf8Strings` below. Note that a plain ``str`` annotation without an explicit :func:`field` spec -still maps to fixed-width ``string(max_length=32)`` for backward -compatibility; opt in to variable-length storage with -``blosc2.field(blosc2.utf8())``. +maps to fixed-width ``string(max_length=32)`` — the same width the decision +path above recommends, so the default is the fast path rather than a +compatibility accident. Opt in to variable-length storage with +``blosc2.field(blosc2.utf8())`` when the length is unbounded. .. _Utf8AndStringDType: From 26c7052f9e35bc6eb1b2eeded30a0c94285b2af7 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 20:18:10 +0200 Subject: [PATCH 76/86] Correct what an over-long max_length actually does The previous commit claimed a value exceeding max_length always raises rather than truncating. That is true only of the validated write paths (constructor, append, extend). extend(validate=False), col[i] = value, Column.assign() and add_column(values=) bypass validation and fall through to NumPy's U semantics, truncating at max_length with no error. Say which paths do which, and keep the conclusion the honest way round: a guessed bound fails as rejected rows on one path and as silently shortened strings on another, so do not lean on the check. Co-Authored-By: Claude Opus 5 --- doc/reference/ctable.rst | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/doc/reference/ctable.rst b/doc/reference/ctable.rst index cbd3341ff..9b31f2f84 100644 --- a/doc/reference/ctable.rst +++ b/doc/reference/ctable.rst @@ -1039,12 +1039,18 @@ Fixed-width wins outright at 32, still leads at 48, and is overtaken between comfortable recommendation rather than a cliff: if your data is bounded at 40, :class:`string` is still the better choice. -Declaring ``max_length`` too small is safe in the sense that matters: a value -that does not fit raises ``ValueError`` on write rather than being silently -truncated. It is not safe for *availability* — the write fails, and a column -whose bound you guessed from a sample can start rejecting rows in production. -When the bound is a guess rather than a fact, that is the signal to use -:func:`utf8`. +Declaring ``max_length`` too small is caught on the **validated** write paths +— the constructor, :meth:`CTable.append` and :meth:`CTable.extend` — which +raise ``ValueError`` naming the column and the offending value. It is +**not** caught on the paths that bypass validation: ``extend(validate=False)``, +``col[i] = value``, :meth:`Column.assign` and ``add_column(values=...)`` fall +through to NumPy's ``U`` semantics and truncate the value at ``max_length`` +with no error. + +So a bound you guessed from a sample can fail in two different ways depending +on how the data arrives — rejected rows on one path, silently shortened +strings on another. When the bound is a guess rather than a fact, use +:func:`utf8` instead of relying on the check. The compressed sizes of :class:`string` and :func:`utf8` are much closer than the in-memory sizes, but by how much depends entirely on the data: the UCS-4 From 44fda19eb701a583900178c809dcd84fbaede388 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 20:26:21 +0200 Subject: [PATCH 77/86] Check add_column(values=) against the declared constraints values= went straight to astype(spec.dtype), and coercing to a fixed-width dtype truncates an over-long string to max_length rather than complaining -- so add_column("c", string(max_length=8), values=["abcdefghijklmnop"]) stored 'abcdefgh' with no error. Route it through validate_column_values(), the same check extend() runs, so it covers every declared constraint rather than just string lengths: a values= of 999 into an int64(le=100) column now raises too. Only this path changes. col[i] = value and Column.assign() keep NumPy's U semantics; they are a released API, and the bypass they have is the one the numeric constraints have as well (col[i] = 999 on int64(le=100) stores 999), so tightening them is a wider decision than this one. add_column(values=) is new in this branch and has no users to break. Co-Authored-By: Claude Opus 5 --- RELEASE_NOTES.md | 5 ++++- doc/reference/ctable.rst | 8 +++++--- src/blosc2/ctable.py | 9 +++++++++ tests/ctable/test_schema_mutations.py | 17 +++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index a7cd28b62..cfd0dc8a7 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -100,7 +100,10 @@ XXX version-specific blurb XXX which matters most for `utf8()` columns: string-returning expressions are evaluated on fixed-width arrays, and the result previously had to be written through the private `t._cols[name].set_all(...)`. A declared default is still - honoured for rows appended later, so the two can be combined. + honoured for rows appended later, so the two can be combined. `values=` is + checked against the constraints declared on the spec, like the constructor + and `extend()` are: without that, coercion to a fixed-width dtype would + truncate an over-long string to `max_length` instead of complaining. - **`blosc2.from_utf8()` / `blosc2.to_utf8()` and `UTF8Array.astype()`** make the conversion between variable-length and fixed-width text an explicit, documented pair. utf8 columns store and filter text compactly, but diff --git a/doc/reference/ctable.rst b/doc/reference/ctable.rst index 9b31f2f84..acc764086 100644 --- a/doc/reference/ctable.rst +++ b/doc/reference/ctable.rst @@ -1043,9 +1043,11 @@ Declaring ``max_length`` too small is caught on the **validated** write paths — the constructor, :meth:`CTable.append` and :meth:`CTable.extend` — which raise ``ValueError`` naming the column and the offending value. It is **not** caught on the paths that bypass validation: ``extend(validate=False)``, -``col[i] = value``, :meth:`Column.assign` and ``add_column(values=...)`` fall -through to NumPy's ``U`` semantics and truncate the value at ``max_length`` -with no error. +``col[i] = value`` and :meth:`Column.assign` fall through to NumPy's ``U`` +semantics and truncate the value at ``max_length`` with no error. This is the +same bypass the numeric constraints have — ``col[i] = 999`` on an +``int64(le=100)`` column stores 999 — except that truncation destroys the +value rather than merely storing a wrong one. So a bound you guessed from a sample can fail in two different ways depending on how the data arrives — rejected rows on one path, silently shortened diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 669ec9dd4..00942680b 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -9216,7 +9216,14 @@ def _add_column_values(self, name: str, col: CompiledColumn, values, n_live: int Returns a list for varlen scalar columns (which are fed row by row) and a dtype-coerced ndarray for the fixed-width ones. + + Constraints declared on the spec are checked here, *before* the + ``astype`` below: coercing to a fixed-width dtype truncates a too-long + string to ``max_length`` instead of complaining, so skipping the check + would silently drop characters. """ + from blosc2.schema_vectorized import validate_column_values + if self._is_varlen_scalar_column(col): values = list(values) if len(values) != n_live: @@ -9224,6 +9231,7 @@ def _add_column_values(self, name: str, col: CompiledColumn, values, n_live: int f"add_column() values= for {name!r} requires {n_live} entries " f"(live rows), got {len(values)}." ) + validate_column_values(col, values) return values arr = values[:] if isinstance(values, blosc2.NDArray) else np.asarray(values) @@ -9236,6 +9244,7 @@ def _add_column_values(self, name: str, col: CompiledColumn, values, n_live: int raise ValueError( f"add_column() values= for {name!r} must have shape {expected}, got {arr.shape}." ) + validate_column_values(col, arr) try: return arr.astype(col.spec.dtype) except (ValueError, OverflowError) as exc: diff --git a/tests/ctable/test_schema_mutations.py b/tests/ctable/test_schema_mutations.py index 698941d0f..b7a9e6319 100644 --- a/tests/ctable/test_schema_mutations.py +++ b/tests/ctable/test_schema_mutations.py @@ -378,6 +378,23 @@ def test_add_column_values_uncoercible_raises(): t.add_column("n", blosc2.int8(), values=["nope"] * 10) +def test_add_column_values_enforces_declared_constraints(): + """values= must not slip past the constraints the spec declares. + + Coercing to a fixed-width dtype truncates an over-long string instead of + complaining, so an unchecked values= would silently drop characters -- + the same check runs for numeric bounds, hence both cases here. + """ + t = CTable(Row, new_data=DATA10) + with pytest.raises(ValueError, match="exceeds max_length=4"): + t.add_column("code", blosc2.string(max_length=4), values=["toolongvalue"] * 10) + with pytest.raises(ValueError, match="violates constraint le="): + t.add_column("bounded", blosc2.int64(le=100), values=[999] * 10) + # A value that does fit is still accepted, uncut. + t.add_column("code", blosc2.string(max_length=4), values=["abcd"] * 10) + assert list(t["code"][:]) == ["abcd"] * 10 + + def test_add_column_values_skips_deleted_rows(): """values= is positional over *live* rows, not physical slots.""" t = CTable(Row, new_data=DATA10) From d673c80985d2bbf24720b50f448ff0865101e31e Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 20:35:20 +0200 Subject: [PATCH 78/86] Address the Copilot review on PR #686 Three of the four findings were real: - _utf8_filled() built [fill] * shape[0], a list of shape[0] pointers to one interned string, before the packer saw any of it. to_utf8() takes any iterable, so itertools.repeat() streams it instead: peak memory for zeros(2_000_000, dtype=StringDType()) drops from 17.3 to 2.1 MiB. - empty/zeros/ones/full/asarray return a UTF8Array when the target dtype is NumPy's StringDType but were annotated `-> NDArray`, so the typing contradicted both the docstring and the behaviour. Widened to `NDArray | blosc2.UTF8Array`. The fourth -- that a column named after a DSL function or an index symbol could compile wrongly -- does not reproduce. A column named `sqrt` alongside a real np.sqrt() call in the same kernel gives the right answer, because operands and calls are distinguished by syntactic position; same for a column named `_i0`. Added a test pinning that rather than changing _param_for() to avoid a collision that does not occur. Co-Authored-By: Claude Opus 5 --- src/blosc2/ndarray.py | 23 +++++++++++++++++------ tests/ctable/test_utf8.py | 23 +++++++++++++++++++++++ tests/test_pandas_udf_engine.py | 20 ++++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/src/blosc2/ndarray.py b/src/blosc2/ndarray.py index 2d4875f3b..da25277db 100644 --- a/src/blosc2/ndarray.py +++ b/src/blosc2/ndarray.py @@ -9,6 +9,7 @@ import builtins import inspect +import itertools import math import weakref from abc import abstractmethod @@ -5781,10 +5782,14 @@ def _utf8_filled(shape, fill: str, **kwargs): f"Variable-length text is 1-D only, got shape {shape}. Use a fixed-width " "' NDArray: +def empty( + shape: int | tuple | list, dtype: np.dtype | str | None = np.float64, **kwargs: Any +) -> NDArray | blosc2.UTF8Array: """Create an empty array. Parameters @@ -5917,7 +5922,9 @@ def nans(shape: int | tuple | list, dtype: np.dtype | str = np.float64, **kwargs return blosc2_ext.nans(shape, chunks, blocks, dtype, **kwargs) -def zeros(shape: int | tuple | list, dtype: np.dtype | str = np.float64, **kwargs: Any) -> NDArray: +def zeros( + shape: int | tuple | list, dtype: np.dtype | str = np.float64, **kwargs: Any +) -> NDArray | blosc2.UTF8Array: """Create an array with zero as the default value for uninitialized portions of the array. @@ -5964,7 +5971,7 @@ def full( fill_value: bytes | int | float | bool, dtype: np.dtype | str = None, **kwargs: Any, -) -> NDArray: +) -> NDArray | blosc2.UTF8Array: """Create an array, with :paramref:`fill_value` being used as the default value for uninitialized portions of the array. @@ -6021,7 +6028,9 @@ def full( return blosc2_ext.full(shape, chunks, blocks, fill_value, dtype, **kwargs) -def ones(shape: int | tuple | list, dtype: np.dtype | str = None, **kwargs: Any) -> NDArray: +def ones( + shape: int | tuple | list, dtype: np.dtype | str = None, **kwargs: Any +) -> NDArray | blosc2.UTF8Array: """Create an array with one as values. The parameters and keyword arguments are the same as for the @@ -6728,7 +6737,9 @@ def _ndarray_asarray_requires_copy( return builtins.any(key in user_kwargs for key in copy_keys) -def asarray(array: Sequence | blosc2.Array, copy: bool | None = None, **kwargs: Any) -> NDArray: +def asarray( + array: Sequence | blosc2.Array, copy: bool | None = None, **kwargs: Any +) -> NDArray | blosc2.UTF8Array: """Convert the `array` to an `NDArray`. Parameters diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index 927c83bc3..e65658d42 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -2214,6 +2214,29 @@ def test_constructors_string_dtype_reject_nd(): blosc2.zeros(3, dtype=STRING_DTYPE, urlpath="unused.b2nd") +def test_constructors_string_dtype_do_not_materialize_a_fill_list(): + """The fill is one string repeated; building a list of it is pure overhead. + + A list would hold shape[0] pointers to the same object before the packer + sees any of them, which is what makes zeros(10_000_000, StringDType()) + expensive for no reason. + """ + import tracemalloc + + n = 1_000_000 + tracemalloc.start() + try: + arr = blosc2.zeros(n, dtype=STRING_DTYPE) + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + assert len(arr) == n + assert arr[0] == "" + # A list of n pointers alone is 8n bytes (~7.6 MiB here) on top of the + # array itself; the streamed build stays far below that. + assert peak < 4 * 2**20, f"peak {peak / 2**20:.1f} MiB suggests a materialized fill list" + + def test_utf8_dispatch_round_trips_conversion(): out = blosc2.full(2, "hé", dtype=STRING_DTYPE) assert list(blosc2.to_utf8(blosc2.from_utf8(out))[:]) == ["hé", "hé"] diff --git a/tests/test_pandas_udf_engine.py b/tests/test_pandas_udf_engine.py index 92f0149df..59a176b1a 100644 --- a/tests/test_pandas_udf_engine.py +++ b/tests/test_pandas_udf_engine.py @@ -427,6 +427,26 @@ def format_room_info(row): result = df.apply(format_room_info, axis=1, engine=blosc2.jit) pd.testing.assert_series_equal(result, expected) + def test_column_named_like_a_dsl_function(self): + """A column name that shadows a DSL builtin must not change meaning. + + The rewrite turns row["sqrt"] into a parameter literally called + `sqrt`, which then coexists with a real sqrt() call in the same + expression; operands and calls are distinguished by position, so both + resolve correctly. Index symbols (`_i0`) are checked for the same + reason. + """ + + def collide(row): + return row["sqrt"] + np.sqrt(row["b"]) + + def index_symbol(row): + return row["_i0"] + row["b"] + + df = pd.DataFrame({"sqrt": [1.0, 2.0], "b": [4.0, 9.0], "_i0": [5.0, 6.0]}) + for fn in (collide, index_symbol): + pd.testing.assert_series_equal(df.apply(fn, axis=1, engine=blosc2.jit), df.apply(fn, axis=1)) + def test_non_identifier_column_label(self): def tag(row): if row["room type"] == "loft": From e517bd76e1ace35445dada01aa1c9e69c863fb16 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Wed, 29 Jul 2026 23:56:38 +0200 Subject: [PATCH 79/86] Size the rank indexes by physical extent, not the live row count delete() tombstones rows in place and only decrements the live count, so live rows can sit past it. Both rank-index builders sized themselves by that count, which cost the dictionary index live rows in equality results and left a utf8 index permanently stale -- built, paid for, and never consulted. utf8 takes len(col), which carries no capacity padding and is the length the staleness check already compares against; dictionary takes the live-data watermark, since its own __len__ is the slot capacity. Also drop the null_rank == 0 branch in the utf8 != null-exclusion: it resolved to an always-empty lookup range, so != returned True for every row of an all-null column. Co-Authored-By: Claude Opus 5 --- src/blosc2/ctable.py | 2 +- src/blosc2/ctable_indexing.py | 49 +++++++++++++++----------- tests/ctable/test_dictionary_column.py | 25 +++++++++++++ tests/ctable/test_utf8.py | 45 +++++++++++++++++++++++ 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index 00942680b..bea401ce3 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -2223,7 +2223,7 @@ def _utf8_index_mask(self, numpy_op, value: str) -> np.ndarray | None: mask[: len(table._cols[self._col_name])] = True if bounds is not None: mask[rows_for_ranks(*bounds)] = False - mask[rows_for_ranks(null_rank, None if null_rank == 0 else null_rank + 1)] = False + mask[rows_for_ranks(null_rank, null_rank + 1)] = False elif bounds is not None: mask[rows_for_ranks(*bounds)] = True return mask diff --git a/src/blosc2/ctable_indexing.py b/src/blosc2/ctable_indexing.py index 9f885a655..9de19a3bb 100644 --- a/src/blosc2/ctable_indexing.py +++ b/src/blosc2/ctable_indexing.py @@ -96,7 +96,7 @@ def _dict_rank_hash(dictionary) -> str: _UTF8_RANK_SPAN = 1 << 20 -def _utf8_rank_arrays(col, n_live: int, null_value: str | None): +def _utf8_rank_arrays(col, n_phys: int, null_value: str | None): """Alphabetical rank per row for a utf8 column, plus its staleness metadata. Sorting by rank is sorting by decoded string, so an ``int32`` rank column @@ -110,9 +110,9 @@ def _utf8_rank_arrays(col, n_live: int, null_value: str | None): both the dictionary index and ``_build_lex_keys``. """ fact = col.factorizer() - codes = np.empty(n_live, dtype=np.int64) - for start in range(0, n_live, _UTF8_RANK_SPAN): - stop = min(start + _UTF8_RANK_SPAN, n_live) + codes = np.empty(n_phys, dtype=np.int64) + for start in range(0, n_phys, _UTF8_RANK_SPAN): + stop = min(start + _UTF8_RANK_SPAN, n_phys) codes[start:stop] = fact.codes_for_span(start, stop) uniques = fact.uniques() n_entries = len(uniques) @@ -129,7 +129,7 @@ def _utf8_rank_arrays(col, n_live: int, null_value: str | None): # Rank order == alphabetical order, so this doubles as the lookup table that # turns a query literal into a rank (np.searchsorted) without touching data. sorted_vocab = uniques[order] - ranks = code_to_rank[codes] if n_entries else np.zeros(n_live, dtype=np.int32) + ranks = code_to_rank[codes] if n_entries else np.zeros(n_phys, dtype=np.int32) # Staleness signals must be O(1) to check: re-deriving the vocabulary would # mean factorizing the column again on every query. Any write already marks # every index stale, so these only have to catch a rebuilt-but-changed @@ -137,7 +137,7 @@ def _utf8_rank_arrays(col, n_live: int, null_value: str | None): meta = { "null_rank": int(null_rank), "vocab_len": int(n_entries), - "n_rows": int(n_live), + "n_rows": int(n_phys), "nbytes": int(col._bytes_used), } return ranks.astype(np.int32, copy=False), meta, sorted_vocab @@ -181,7 +181,7 @@ def __init__( null_rank: np.int32, null_code: int, nullable: bool, - n_live: int, + n_phys: int, ): self._codes = codes # int32 NDArray self._code_to_rank = code_to_rank # int32 array mapping code -> rank @@ -189,15 +189,17 @@ def __init__( self._null_code = null_code self._nullable = nullable self.dtype = np.dtype(np.int32) - # The codes array carries capacity padding beyond the live rows; expose only - # the live range so the index sidecars match n_rows (no padding → the - # zero-permutation window read engages instead of falling back). - self.shape = (n_live,) + # The codes array carries capacity padding beyond the written rows; expose + # only the physical extent so the index sidecars match n_rows (no padding → + # the zero-permutation window read engages instead of falling back). It has + # to be the physical extent and not the live count: tombstoned rows keep + # their positions, so live rows can sit past the live count. + self.shape = (n_phys,) self.ndim = 1 - chunk0 = codes.chunks[0] if codes.chunks else n_live - block0 = codes.blocks[0] if codes.blocks else n_live - self.chunks = (min(chunk0, n_live),) - self.blocks = (min(block0, n_live),) + chunk0 = codes.chunks[0] if codes.chunks else n_phys + block0 = codes.blocks[0] if codes.blocks else n_phys + self.chunks = (min(chunk0, n_phys),) + self.blocks = (min(block0, n_phys),) def __getitem__(self, key): codes_slice = np.asarray(self._codes[key], dtype=np.int32) @@ -889,9 +891,13 @@ def create_index( # noqa: C901 is_utf8 = isinstance(self._schema.columns_by_name[col_name].spec, UTF8Spec) utf8_rank_meta = None if is_utf8: - n_live = self._n_rows if self._n_rows is not None else len(self._valid_rows) + # Span the physical extent, not the live count: delete() tombstones in + # place, so live rows sit past _n_rows. A utf8 column carries no + # capacity padding (__len__ is persisted + pending), and this is the + # same length _utf8_rank_index_stale() compares the meta against. + n_phys = len(col_arr) ranks_arr, utf8_rank_meta, utf8_vocab = _utf8_rank_arrays( - col_arr, n_live, self[col_name].null_value + col_arr, n_phys, self[col_name].null_value ) col_arr = blosc2.asarray(ranks_arr) @@ -900,7 +906,10 @@ def create_index( # noqa: C901 dict_rank_meta = None if is_dictionary: dict_col = col_arr - n_live = self._n_rows if self._n_rows is not None else len(self._valid_rows) + # Physical extent again, but len(dict_col) is the slot *capacity*, so + # take the live-data watermark instead: it covers every live row while + # still excluding the trailing padding. + n_phys = self._resolve_last_pos() dictionary = list(dict_col.dictionary) n_entries = len(dictionary) code_to_rank = _dict_code_to_rank(dictionary) @@ -910,7 +919,7 @@ def create_index( # noqa: C901 dict_hash = _dict_rank_hash(dictionary) dict_rank_meta = {"null_rank": int(null_rank), "dict_hash": dict_hash, "dict_len": n_entries} col_arr = _DictRankWrapper( - dict_col.codes, code_to_rank, null_rank, null_code, dict_col.spec.nullable, n_live + dict_col.codes, code_to_rank, null_rank, null_code, dict_col.spec.nullable, n_phys ) is_persistent = self._storage.index_anchor_path(col_name) is not None @@ -932,7 +941,7 @@ def create_index( # noqa: C901 else: # In-memory path: materialise ranks as a proper NDArray (small tables only). if is_dictionary: - codes = np.asarray(dict_col.codes[:n_live], dtype=np.int32) + codes = np.asarray(dict_col.codes[:n_phys], dtype=np.int32) ranks_arr = code_to_rank[codes] if dict_col.spec.nullable: ranks_arr[codes == null_code] = null_rank diff --git a/tests/ctable/test_dictionary_column.py b/tests/ctable/test_dictionary_column.py index a4cfead2f..644828271 100644 --- a/tests/ctable/test_dictionary_column.py +++ b/tests/ctable/test_dictionary_column.py @@ -659,6 +659,31 @@ class Row: assert results["scan"]["apple"][0] == ["apple", "apple"] +def test_dictionary_index_spans_deleted_rows(tmp_path): + """The rank index has to cover the physical extent, not the live row count. + + delete() tombstones in place and only decrements the live count, so live + rows can sit past it. An index sized by that count silently drops them + from equality results. + """ + + @dataclass + class Row: + c: str = blosc2.field(blosc2.dictionary()) + + values = ["pear", "apple", "cherry", "apple", "banana", "apple"] + t = CTable(Row, urlpath=str(tmp_path / "t.b2t"), mode="w") + t.extend({"c": values}, validate=False) + t._flush_varlen_columns() + t.delete(0) + t.create_index("c", kind="full") + + assert t["c"]._dictionary_index_mask("apple") is not None + # The trailing "apple" is live and must not be lost to the index. + assert sorted(t[t["c"] == "apple"]["c"][:]) == ["apple"] * 3 + assert sorted(t[t["c"] != "apple"]["c"][:]) == ["banana", "cherry"] + + def test_dict_rank_staleness_uses_value_epoch(tmp_path): """The staleness check must not re-hash the whole dictionary per query.""" from blosc2.ctable_indexing import _dict_rank_hash diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index e65658d42..1041ec497 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -1848,6 +1848,51 @@ def test_ctable_utf8_index_pred_falls_back(tmp_path): np.testing.assert_array_equal(t["c"]._utf8_scalar_mask(np.equal, "apple")[:3], [False, False, True]) +def test_ctable_utf8_index_spans_deleted_rows(tmp_path): + """The index has to cover the physical extent, not the live row count. + + delete() tombstones in place, so live rows sit past the live count. Sizing + the index by that count leaves it permanently stale -- built, paid for, and + never consulted. + """ + from dataclasses import make_dataclass + + import numpy as np + + values = ["pear", "apple", "cherry", "apple", "banana", "apple"] + row_cls = make_dataclass("Row", [("c", str, blosc2.field(blosc2.utf8()))]) + t = blosc2.CTable(row_cls, urlpath=str(tmp_path / "t.b2t"), mode="w") + t.extend({"c": values}, validate=False) + t._flush_varlen_columns() + t.delete(0) + t.create_index("c", kind="full") + + meta = t._get_index_catalog()["c"]["full"]["utf8_rank"] + assert not t._utf8_rank_index_stale("c", meta) + assert t["c"]._utf8_index_mask(np.equal, "apple") is not None + # Every live "apple" comes back, including the one at the last position. + assert sorted(t[t["c"] == "apple"]["c"][:]) == ["apple"] * 3 + + +def test_ctable_utf8_index_ne_on_all_null_column(tmp_path): + """A null satisfies no comparison, so ``!= x`` on an all-null column is empty. + + With no non-null distinct values the null rank is 0, which used to send the + null-exclusion lookup down an always-empty branch. + """ + from dataclasses import make_dataclass + + row_cls = make_dataclass("Row", [("c", str, blosc2.field(blosc2.utf8(nullable=True)))]) + t = blosc2.CTable(row_cls, urlpath=str(tmp_path / "t.b2t"), mode="w") + t.extend({"c": [None] * 6}, validate=False) + t._flush_varlen_columns() + t.create_index("c", kind="full") + + assert t._get_index_catalog()["c"]["full"]["utf8_rank"]["null_rank"] == 0 + assert len(t[t["c"] != "x"]["c"][:]) == 0 + assert len(t[t["c"] == "x"]["c"][:]) == 0 + + # --------------------------------------------------------------------------- # Fixed-width conversion pair: astype / from_utf8 / to_utf8 # --------------------------------------------------------------------------- From 2ac13b1e08f2d79fe165c8f6ed82076b666005bb Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 30 Jul 2026 07:37:46 +0200 Subject: [PATCH 80/86] Count blocks, not chunks, when a bucket is as wide as a block The bucket-selectivity gate had a separate branch for the case where a bucket spans a whole block, and it measured the wrong thing: the fraction of chunks holding any selected bucket, or a flat 1.0 for a 1-D mask. Both overstate the cost, so the gate declined the selective queries a bucket index exists to serve. The branch was also unnecessary -- with one bucket per block the grouping below it reduces to the mask itself and already gives the right answer, so it just goes. Co-Authored-By: Claude Opus 5 --- src/blosc2/indexing.py | 5 +++-- tests/ctable/test_ctable_indexing.py | 11 +++++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/blosc2/indexing.py b/src/blosc2/indexing.py index f1c2f0cc5..6826cf086 100644 --- a/src/blosc2/indexing.py +++ b/src/blosc2/indexing.py @@ -6863,9 +6863,10 @@ def _bucket_block_fraction(bucket_masks: np.ndarray, bucket: dict) -> float: masks = np.asarray(bucket_masks, dtype=bool) if masks.size == 0: return 0.0 + # A bucket at least as wide as a block covers whole blocks, so the clamp to 1 + # is not a special case: the grouping below then reduces to the mask itself, + # and the fraction of blocks read equals the fraction of buckets selected. per_block = max(1, int(bucket["nav_segment_len"]) // int(bucket["bucket_len"])) - if per_block <= 1: - return float(masks.any(axis=1).mean()) if masks.ndim > 1 else 1.0 n_blocks = math.ceil(masks.shape[-1] / per_block) padded = np.zeros((*masks.shape[:-1], n_blocks * per_block), dtype=bool) padded[..., : masks.shape[-1]] = masks diff --git a/tests/ctable/test_ctable_indexing.py b/tests/ctable/test_ctable_indexing.py index c1f0078c1..af8fccde2 100644 --- a/tests/ctable/test_ctable_indexing.py +++ b/tests/ctable/test_ctable_indexing.py @@ -1249,6 +1249,17 @@ def test_bucket_gate_counts_blocks_not_buckets(): assert frac(np.zeros((1, 640), dtype=bool), geom) == 0.0 + # A bucket at least as wide as a block covers whole blocks, so the fraction of + # blocks read is just the fraction of buckets selected -- not, as it once was, + # the fraction of *chunks* touched (or a flat 1.0 for a 1-D mask), both of which + # overstate the cost and decline plans the index exists to serve. + for bucket_len in (16384, 32768): # one block per bucket, and two + wide = {"nav_segment_len": 16384, "bucket_len": bucket_len} + selective = np.zeros((1, 10), dtype=bool) + selective[0, 0] = True + assert frac(selective, wide) == 0.1 + assert frac(selective[0], wide) == 0.1 # 1-D mask, same answer + def test_bucket_plan_gate_matches_block_fraction(): """The planner must take a bucket plan only when it prunes actual blocks.""" From 9a3efa66adfda8f2ad81b33fc68487a840097ef2 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 30 Jul 2026 07:37:53 +0200 Subject: [PATCH 81/86] Attach jit hints as notes instead of rebuilding the exception raise type(e)(msg) assumes a one-argument constructor. The trace hint is attached to whatever the traced function raised, so any exception needing more arguments -- or refusing a bare string -- surfaced as a TypeError about that constructor, and the real failure was lost. add_note() keeps the exception's type, args and traceback intact. The guidance still prints with the traceback, but it is no longer part of str(e), so the kwargs test reads the notes as well as the message. Co-Authored-By: Claude Opus 5 --- src/blosc2/proxy.py | 21 +++++++++---------- tests/ndarray/test_jit_dsl_dispatch.py | 28 ++++++++++++++++++++++++++ tests/test_pandas_udf_engine.py | 4 +++- 3 files changed, 40 insertions(+), 13 deletions(-) diff --git a/src/blosc2/proxy.py b/src/blosc2/proxy.py index 80d08d81f..425416359 100644 --- a/src/blosc2/proxy.py +++ b/src/blosc2/proxy.py @@ -1255,18 +1255,15 @@ def wrapper(*args, **func_kwargs): try: retval = func(*new_args, **func_kwargs) except Exception as e: - hints = [ - hint - for hint in ( - _wide_frame_hint( - e, getattr(func, "__name__", "the function"), _signature_params(func) - ), - _trace_hint, - ) - if hint is not None - ] - if hints: - raise type(e)("\n".join([str(e), *hints])) from e + # Notes rather than a re-raise: type(e)(msg) assumes a one-argument + # constructor, and any exception needing more (or rejecting a bare + # string) would surface as a TypeError instead of the real failure. + for hint in ( + _wide_frame_hint(e, getattr(func, "__name__", "the function"), _signature_params(func)), + _trace_hint, + ): + if hint is not None: + e.add_note(hint) raise # Treat return value diff --git a/tests/ndarray/test_jit_dsl_dispatch.py b/tests/ndarray/test_jit_dsl_dispatch.py index f9e421725..e18b3a6ca 100644 --- a/tests/ndarray/test_jit_dsl_dispatch.py +++ b/tests/ndarray/test_jit_dsl_dispatch.py @@ -158,6 +158,34 @@ def scalar_flag(a, b, flag): np.testing.assert_allclose(scalar_flag(a, b, False), a - b) +def test_trace_hint_keeps_the_original_error(): + """A hint must not replace the failure it annotates. + + The hint used to be re-raised as ``type(e)(msg)``, which assumes a + one-argument constructor; anything needing more surfaced as a TypeError + about that constructor and the real error was lost. + """ + + class TwoArgError(Exception): + def __init__(self, code, detail): + super().__init__(f"{code}: {detail}") + self.code = code + + @blosc2.jit + def cf_func(a, b, flag): + if flag: + raise TwoArgError(7, "boom") + return a - b + + a = np.arange(10, dtype=np.float64) + with pytest.raises(TwoArgError) as excinfo: + cf_func(a, a, True) + assert excinfo.value.code == 7 + assert "boom" in str(excinfo.value) + # The hint still reaches the user, as a note on the original exception. + assert any("control flow" in note for note in getattr(excinfo.value, "__notes__", [])) + + def test_jit_dsl_route_rejects_broadcasting(): @blosc2.jit def kernel(a, b, n): diff --git a/tests/test_pandas_udf_engine.py b/tests/test_pandas_udf_engine.py index 59a176b1a..802668d6e 100644 --- a/tests/test_pandas_udf_engine.py +++ b/tests/test_pandas_udf_engine.py @@ -368,7 +368,9 @@ def dsl(a, b): for name, func in (("traced", traced), ("dsl", dsl)): with pytest.raises(TypeError) as excinfo: func(**df) - message = str(excinfo.value) + # The guidance rides along as a note (it prints with the traceback); + # rebuilding the exception to append it would assume its constructor. + message = "\n".join([str(excinfo.value), *getattr(excinfo.value, "__notes__", [])]) assert name in message assert "'note'" in message assert "**df[['a', 'b']]" in message From cf450794a6b0f9c73f28c8d06c6d85de97b5c98b Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 30 Jul 2026 07:38:00 +0200 Subject: [PATCH 82/86] Key the string-width cache on the operand dtypes too The inferred width follows the operand dtypes, but the cache was validated against the expression text alone. lazyexpr(expr, operands) rebinds in place and leaves that text untouched, so rebinding wider operands was answered from the narrower build: the output container came out too small and the concat truncated (or refused to evaluate under strict_miniexpr). The dtypes are collected before the check, which costs an attribute read per operand; what the key still protects is the miniexpr compile. Co-Authored-By: Claude Opus 5 --- src/blosc2/lazyexpr.py | 13 +++++++++---- tests/ndarray/test_string_output.py | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/blosc2/lazyexpr.py b/src/blosc2/lazyexpr.py index 75baae649..4ee2eadd6 100644 --- a/src/blosc2/lazyexpr.py +++ b/src/blosc2/lazyexpr.py @@ -3637,9 +3637,6 @@ def _miniexpr_string_dtype(self): involved. Returns None when no string is involved or miniexpr cannot compile the expression, leaving the numpy path in charge. """ - cached = getattr(self, "_me_str_dtype_", None) - if cached is not None and self._me_str_expr_ == self.expression: - return cached[0] try: operands = self.operands if not operands or any(v is None for v in operands.values()): @@ -3652,6 +3649,14 @@ def _miniexpr_string_dtype(self): dtypes[k] = dt if not any(np.dtype(dt).kind in "US" for dt in dtypes.values()): return None + # The width follows the operand dtypes, not just the expression text, so + # both go in the key: rebinding the same expression to wider operands + # must not be answered from the narrower build's cache. Collecting the + # dtypes is cheap; what the key protects is the miniexpr compile below. + key = (self.expression, tuple((k, str(dt)) for k, dt in dtypes.items())) + cached = getattr(self, "_me_str_dtype_", None) + if cached is not None and self._me_str_key_ == key: + return cached[0] from blosc2 import blosc2_ext out = blosc2_ext.me_output_dtype(self.expression, dtypes) @@ -3660,7 +3665,7 @@ def _miniexpr_string_dtype(self): if out is not None and np.dtype(out).kind not in "US": out = None self._me_str_dtype_ = (out,) - self._me_str_expr_ = self.expression + self._me_str_key_ = key return out @property diff --git a/tests/ndarray/test_string_output.py b/tests/ndarray/test_string_output.py index 46acdee49..c2a85278e 100644 --- a/tests/ndarray/test_string_output.py +++ b/tests/ndarray/test_string_output.py @@ -246,3 +246,21 @@ def test_block_larger_than_the_eval_block(): got = ("x=" + arr).compute(strict_miniexpr=True) assert list(got[:]) == list(np_add("x=", values)) + + +def test_rebinding_wider_operands_rewidens(): + # lazyexpr(expr, operands) rebinds the operands in place and leaves the + # expression text alone. The inferred width follows the operand dtypes, so a + # width cached under the expression alone would size the output from the first, + # narrower binding and the concat would truncate. + narrow = np.array(["ab"] * 16, dtype="= expected.dtype.itemsize + got = expr.compute(strict_miniexpr=True) + assert list(got[:]) == list(expected) From 672c1020b466a320c9df7d6562d91d88d38f58e8 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 30 Jul 2026 08:05:36 +0200 Subject: [PATCH 83/86] Drop the stale nested-utf8-leaf disclaimer from utf8() 86d466f0 made dotted utf8 leaves filterable and updated the string-type guide, but left this docstring claiming they are unsupported. Nothing replaces the sentence: it described a limitation that no longer exists, and the paragraph around it already lists what utf8 columns support. Co-Authored-By: Claude Opus 5 --- src/blosc2/schema.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/blosc2/schema.py b/src/blosc2/schema.py index 738fcc1f2..bdb131ae0 100644 --- a/src/blosc2/schema.py +++ b/src/blosc2/schema.py @@ -869,8 +869,7 @@ def utf8(*, nullable: bool = False, null_value: str | None = None) -> UTF8Spec: :meth:`CTable.group_by` keys, :meth:`CTable.sort_by`, Arrow/Parquet interop, and :meth:`CTable.create_index`, which indexes the alphabetical rank of each value and accelerates sorting and scalar comparisons (but - not ``startswith``/substring searches, which no index covers). Nested - (dotted) utf8 leaves in an expression are not supported yet. See + not ``startswith``/substring searches, which no index covers). See :ref:`ChoosingStringType` for a full comparison with :class:`string` and :func:`vlstring`. From 92788a6680b753adfb54bb348992ab8612b9ed44 Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 30 Jul 2026 08:08:16 +0200 Subject: [PATCH 84/86] Assert the string fill is flat in n, not under a fixed ceiling The absolute 4 MiB peak bound tracked the platform's baseline allocation rather than the behaviour under test: Windows CI reported 6.2 MiB for the same streamed build that peaks at 2.1 MiB here, so the test failed there while nothing had materialized a fill list. Compare the peak at two sizes instead. The streamed build is flat in shape[0] -- 2.05 MiB from 250k rows to 4M here -- while a materialized list adds 8 bytes per row, ~27 MiB across the two sizes used. The baseline cancels, so the margin does not have to be tuned per platform. Co-Authored-By: Claude Opus 5 --- tests/ctable/test_utf8.py | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index 1041ec497..0dea10388 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -2265,21 +2265,33 @@ def test_constructors_string_dtype_do_not_materialize_a_fill_list(): A list would hold shape[0] pointers to the same object before the packer sees any of them, which is what makes zeros(10_000_000, StringDType()) expensive for no reason. + + What gives that away is peak memory *scaling* with shape[0]: the streamed + build is flat in it. Asserting a flat peak instead of an absolute ceiling + cancels the platform's baseline allocation, which is several MiB on Windows + and half that elsewhere -- a fixed bound only tracks that baseline. """ import tracemalloc - n = 1_000_000 - tracemalloc.start() - try: - arr = blosc2.zeros(n, dtype=STRING_DTYPE) - _, peak = tracemalloc.get_traced_memory() - finally: - tracemalloc.stop() - assert len(arr) == n - assert arr[0] == "" - # A list of n pointers alone is 8n bytes (~7.6 MiB here) on top of the - # array itself; the streamed build stays far below that. - assert peak < 4 * 2**20, f"peak {peak / 2**20:.1f} MiB suggests a materialized fill list" + def peak_for(n): + tracemalloc.start() + try: + arr = blosc2.zeros(n, dtype=STRING_DTYPE) + _, peak = tracemalloc.get_traced_memory() + finally: + tracemalloc.stop() + assert len(arr) == n + assert arr[0] == "" + return peak + + small = peak_for(500_000) + large = peak_for(4_000_000) + # 8x the rows is 8x the pointer list: materializing one adds ~27 MiB between + # these two sizes, where the streamed build does not move at all. Both sizes + # are well past the point where the chunk size stops growing, so the transient + # buffer -- the whole of the peak -- is the same for each. + grown = (large - small) / 2**20 + assert grown < 4.0, f"peak grew {grown:.1f} MiB with 8x the rows: a fill list was materialized" def test_utf8_dispatch_round_trips_conversion(): From e7b336e7116d4ce2c4e3daf03e391a200997c7ab Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 30 Jul 2026 10:05:45 +0200 Subject: [PATCH 85/86] Stand the min/max shortcut down on any hole, not just fresh ones The summaries are built over the column's physical array, so a tombstoned row still contributes its value to its block's extrema. Keying the guard on the visibility epoch only caught rows deleted after the build; a delete followed by create_index() left the epoch matching and the shortcut on, and min() then reported a deleted row's value (0 instead of 1000 over arange(100_000) minus the first thousand rows, and symmetrically for max()). The same mismatch reached the straddling-block rescan, which indexes rows logically while the summary blocks it complements are physical. Both line up exactly while every slot below the watermark is live, so that is what the guard now asks. built_visibility_epoch had no other reader and goes with it. Co-Authored-By: Claude Opus 5 --- src/blosc2/ctable.py | 25 +++++++++++++++---------- src/blosc2/ctable_indexing.py | 8 ++------ tests/ctable/test_ctable_indexing.py | 13 +++++++++++++ 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/src/blosc2/ctable.py b/src/blosc2/ctable.py index bea401ce3..cab1513dd 100644 --- a/src/blosc2/ctable.py +++ b/src/blosc2/ctable.py @@ -3013,12 +3013,16 @@ def _summary_minmax_source(self): absent, or in-memory-only index. Appends mark the index stale, so they are covered. Deletions are *not* - (``delete()`` bumps the visibility epoch and leaves the index usable for - queries), and a deleted row keeps contributing its value to the block it - sits in — so a visibility epoch that has moved since the index was built - disqualifies the shortcut. Capacity padding *does* enter the summaries; - ``segment_len`` is returned so the caller can drop the padded tail and - rescan the one block that straddles the live/padded boundary. + (``delete()`` tombstones in place and leaves the index usable for + queries), and the summaries are built over the column's *physical* + array, where a tombstoned row keeps contributing its value to its + block — so any hole at all disqualifies the shortcut, whether it was + punched before or after the build. With no holes the physical and + logical row numbers coincide, which is what lets the caller mix + summary blocks with a rescanned tail. Capacity padding *does* enter + the summaries; ``segment_len`` is returned so the caller can drop the + padded tail and rescan the one block that straddles the live/padded + boundary. """ table = self._table if table.base is not None: @@ -3045,10 +3049,11 @@ def _summary_minmax_source(self): desc = root._get_index_catalog().get(self._col_name) if not desc or desc.get("stale", False): return None - # Deleted rows still sit in their block and still contribute to its - # extrema, so any deletion since the build invalidates the shortcut. - built_vis = desc.get("built_visibility_epoch") - if built_vis is None or root._storage.get_epoch_counters()[1] != built_vis: + # A tombstoned row still sits in its block and still contributes to that + # block's extrema, and the summaries index physical slots while min() + # reads logical rows. Both only line up while every slot below the + # watermark is live. + if root._n_rows is None or root._n_rows != root._resolve_last_pos(): return None levels = desc.get("levels") or {} level = "block" if "block" in levels else next(iter(levels), None) diff --git a/src/blosc2/ctable_indexing.py b/src/blosc2/ctable_indexing.py index 9de19a3bb..800f17dc2 100644 --- a/src/blosc2/ctable_indexing.py +++ b/src/blosc2/ctable_indexing.py @@ -832,9 +832,8 @@ def create_index( # noqa: C901 descriptor["token"] = token descriptor["dtype"] = str(np.dtype(dtype)) descriptor["expr_values_path"] = getattr(expr_arr, "urlpath", None) - value_epoch, visibility_epoch = self._storage.get_epoch_counters() + value_epoch, _ = self._storage.get_epoch_counters() descriptor["built_value_epoch"] = value_epoch - descriptor["built_visibility_epoch"] = visibility_epoch catalog[token] = descriptor self._storage.save_index_catalog(catalog) self._invalidate_index_catalog_cache() @@ -972,9 +971,8 @@ def create_index( # noqa: C901 _persist_utf8_vocab(full, utf8_rank_meta, utf8_vocab) full["utf8_rank"] = utf8_rank_meta - value_epoch, visibility_epoch = self._storage.get_epoch_counters() + value_epoch, _ = self._storage.get_epoch_counters() descriptor["built_value_epoch"] = value_epoch - descriptor["built_visibility_epoch"] = visibility_epoch if is_persistent: # Use column name as token so sibling columns in compact stores get @@ -1062,7 +1060,6 @@ def compact_index( finally: _PERSISTENT_INDEXES.pop(proxy_key, None) updated_desc["built_value_epoch"] = descriptor.get("built_value_epoch", 0) - updated_desc["built_visibility_epoch"] = descriptor.get("built_visibility_epoch") catalog[lookup_key] = updated_desc self._storage.save_index_catalog(catalog) self._invalidate_index_catalog_cache() @@ -1074,7 +1071,6 @@ def compact_index( token = descriptor["token"] updated_desc = _copy_descriptor(store["indexes"].get(token, descriptor)) updated_desc["built_value_epoch"] = descriptor.get("built_value_epoch", 0) - updated_desc["built_visibility_epoch"] = descriptor.get("built_visibility_epoch") catalog[lookup_key] = updated_desc self._storage.save_index_catalog(catalog) self._invalidate_index_catalog_cache() diff --git a/tests/ctable/test_ctable_indexing.py b/tests/ctable/test_ctable_indexing.py index af8fccde2..e2cc901ab 100644 --- a/tests/ctable/test_ctable_indexing.py +++ b/tests/ctable/test_ctable_indexing.py @@ -1366,6 +1366,19 @@ def test_summary_minmax_declines_after_delete(tmpdir): assert t["c"].min() == min(t["c"][:].tolist()) +def test_summary_minmax_declines_when_built_over_holes(tmpdir): + """A row deleted *before* the build is still in its block when the summary is + written, so a fresh index over a holey column must not enable the shortcut.""" + t, _ = _minmax_table(tmpdir / "prebuilt.b2t", 100_000, kind=None) + t.delete(slice(0, 1000)) # drop the 1000 smallest + for col in ("c", "n", "f"): + t.create_index(col, kind="summary") + assert t["n"]._summary_minmax_source() is None + assert t["n"].min() == 1005 + assert t["n"].max() == 100_004 + assert t["c"].min() == min(t["c"][:].tolist()) + + def test_summary_minmax_shortcut_still_taken(tmpdir): """The padding fix must not disable the shortcut on the common padded table.""" t, _ = _minmax_table(tmpdir / "fast.b2t", 100_000) From c5f5823f2e42a4d77305cd57ebec01c9c61254ef Mon Sep 17 00:00:00 2001 From: Francesc Alted Date: Thu, 30 Jul 2026 10:05:45 +0200 Subject: [PATCH 86/86] Make a string result refuse clashing utf8 null sentinels utf8_span_eval took the first non-None sentinel it found and stamped it on every null row of the result, so an expression over two utf8 operands with different null_values relabelled one operand's nulls as the other's -- and which one won depended on the operand mapping's order. A result column has one sentinel, so there is no answer here to pick; refuse instead, and only where it shows. A boolean result forces nulls False whatever they were spelled as, and keeps working. Co-Authored-By: Claude Opus 5 --- src/blosc2/_utf8_array.py | 18 ++++++++++++++++-- tests/ctable/test_utf8.py | 20 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/blosc2/_utf8_array.py b/src/blosc2/_utf8_array.py index 7b5691108..e7ad86182 100644 --- a/src/blosc2/_utf8_array.py +++ b/src/blosc2/_utf8_array.py @@ -151,7 +151,9 @@ def utf8_span_eval( Nulls are materialized to ``""`` so no string kernel ever sees a sentinel, and nullity is re-applied afterwards: a boolean result is forced ``False`` - (SQL ``WHERE`` semantics), a string result gets the sentinel back. + (SQL ``WHERE`` semantics), a string result gets the sentinel back. A string + result therefore needs the operands to agree on one sentinel, and raises + ``ValueError`` when they do not. Span operands are handed over as blosc2 arrays rather than NumPy ones: the NumPy route evaluates through ``slices_eval``, which never reaches @@ -163,7 +165,12 @@ def utf8_span_eval( n_logical = min(len(a) for a in arrays.values()) compute_kwargs = {"strict_miniexpr": True} if strict else {} - null_value = next((v for v in sentinels.values() if v is not None), None) + # One result column, so one sentinel. Reading it off whichever operand came + # first would make the answer depend on the operand mapping's order, so a + # disagreement is refused instead -- but only where it shows, on a string + # result; a boolean one forces nulls False whatever they were spelled as. + distinct_sentinels = sorted({v for v in sentinels.values() if v is not None}) + null_value = distinct_sentinels[0] if distinct_sentinels else None out = None utf8_out = None @@ -191,6 +198,13 @@ def utf8_span_eval( res.astype(f" 1: + raise ValueError( + f"utf8 operands carry different null sentinels ({distinct_sentinels}); " + "a string result can only have one, and picking one of them would " + "silently relabel the other's nulls. Give the operands a common " + "null_value, or compute on fixed-width arrays (blosc2.from_utf8)." + ) if utf8_out is None: utf8_out = UTF8Array(blosc2.utf8(null_value=null_value)) # tolist() gives plain str, which is UTF8Array.extend's fast path. diff --git a/tests/ctable/test_utf8.py b/tests/ctable/test_utf8.py index 0dea10388..13bdd3a07 100644 --- a/tests/ctable/test_utf8.py +++ b/tests/ctable/test_utf8.py @@ -1341,6 +1341,26 @@ def test_utf8_array_constructor_with_spec_and_nulls(): assert list(arr[:]) == ["a", "", "c"] +def test_utf8_string_expr_rejects_clashing_sentinels(): + """One result column means one sentinel, so operands must agree on it; the + old first-wins pick relabelled the other operand's nulls silently.""" + a = blosc2.utf8_array(["x", None, "z"], blosc2.utf8(null_value="")) + b = blosc2.utf8_array(["1", "2", None], blosc2.utf8(null_value="")) + with pytest.raises(ValueError, match="different null sentinels"): + blosc2.lazyexpr("a + b", {"a": a, "b": b}).compute() + # A boolean result never carries a sentinel, so it is unaffected. + assert list(blosc2.lazyexpr("a > b", {"a": a, "b": b}).compute()) == [True, False, False] + + +def test_utf8_string_expr_shared_sentinel_survives(): + spec = blosc2.utf8(null_value="") + a = blosc2.utf8_array(["x", None, "z"], spec) + b = blosc2.utf8_array(["1", "2", None], spec) + res = blosc2.lazyexpr("a + b", {"a": a, "b": b}).compute() + assert list(res[:]) == ["x1", "", ""] + assert res.spec.null_value == "" + + def test_utf8_array_ctor_rejects_none_if_not_null(): with pytest.raises(TypeError, match="not nullable"): blosc2.utf8_array(["a", None])