diff --git a/changes/4203.bugfix.md b/changes/4203.bugfix.md new file mode 100644 index 0000000000..42ca977193 --- /dev/null +++ b/changes/4203.bugfix.md @@ -0,0 +1 @@ +Fixed silent data corruption in the experimental `FusedCodecPipeline`: reordering or duplicating fancy-index reads (e.g. `arr[perm, :]`, `arr.oindex[[0, 0, 1], :]`) on uncompressed, crc-free sharded arrays could return the shard in natural order because the vectorized whole-shard decode accepted any selection whose output shape matched the shard shape. The bulk decode now fires only for identity full-shard reads, declines structured dtypes (whose byte-order handling it lacks), and requires shard-index offsets to exactly tile the data section, so corrupt indexes with overlapping or out-of-range offsets can no longer be served as array data. diff --git a/src/zarr/codecs/sharding.py b/src/zarr/codecs/sharding.py index 8f23606011..cdfdae6c89 100644 --- a/src/zarr/codecs/sharding.py +++ b/src/zarr/codecs/sharding.py @@ -53,10 +53,12 @@ from zarr.core.config import config as zarr_config from zarr.core.dtype.common import HasEndianness from zarr.core.dtype.npy.int import UInt64 +from zarr.core.dtype.npy.structured import Struct from zarr.core.indexing import ( BasicIndexer, ChunkProjection, SelectorTuple, + SliceDimIndexer, _lexicographic_order, colexicographic_order_coords, get_indexer, @@ -109,6 +111,32 @@ class ShardingCodecIndexLocation(metaclass=_DeprecatedStrEnumMeta): ) +def _is_identity_full_read(indexer: Any, shard_shape: tuple[int, ...]) -> bool: + """True when `indexer` selects every element of a `shard_shape` array in + natural order: one whole-dimension, step-1 `SliceDimIndexer` per dimension. + + Structural on purpose, not `isinstance(indexer, BasicIndexer)`: a full + `arr[:]` read reaches the shard as an `OrthogonalIndexer`, so a type gate + would silently disable the bulk fast path for the most common case. Any + gather (integer-array / boolean / coordinate selection), subset, strided, + or integer-scalar selection fails the per-dimension check — output shape + alone is not enough, because a reordering or duplicating selection can have + the same shape as the shard while requiring `chunk_selection` / + `out_selection` to be honored. + """ + dim_indexers = getattr(indexer, "dim_indexers", None) + if dim_indexers is None or len(dim_indexers) != len(shard_shape): + return False + return all( + isinstance(dim_indexer, SliceDimIndexer) + and dim_indexer.dim_len == dim_len + and dim_indexer.start == 0 + and dim_indexer.stop == dim_len + and dim_indexer.step == 1 + for dim_indexer, dim_len in zip(dim_indexers, shard_shape, strict=True) + ) + + def _parse_index_location(data: object) -> IndexLocation: if isinstance(data, str) and data in INDEX_LOCATION: return data # type: ignore[return-value] @@ -192,12 +220,17 @@ def is_all_empty(self) -> bool: def get_full_chunk_map(self) -> npt.NDArray[np.bool_]: return np.not_equal(self.offsets_and_lengths[..., 0], MAX_UINT_64) - def is_dense(self, chunk_byte_length: int) -> bool: - """True when every chunk is present, fixed-length, and uniquely placed. - - Used to gate the vectorized whole-shard decode: a dense fixed-size shard - is a regular grid of equal-length payloads, so it can be reshaped/scattered - in bulk rather than decoded chunk-by-chunk. + def is_dense(self, chunk_byte_length: int, *, data_section_start: int) -> bool: + """True when the chunk payloads exactly tile the shard's data section. + + Every chunk must be present with length `chunk_byte_length`, and the + sorted offsets must be exactly `data_section_start + i * chunk_byte_length` + for `i` in `0..n_chunks-1`: no gaps, no overlaps, and nothing outside the + data section (a corrupt index could otherwise point chunks into the + index region or out of the blob). Used to gate the vectorized + whole-shard decode: a dense fixed-size shard is a regular grid of + equal-length payloads, so it can be reshaped/scattered in bulk rather + than decoded chunk-by-chunk. """ offsets = self.offsets_and_lengths[..., 0].reshape(-1) lengths = self.offsets_and_lengths[..., 1].reshape(-1) @@ -207,8 +240,10 @@ def is_dense(self, chunk_byte_length: int) -> bool: # all the same fixed length if not bool(np.all(lengths == chunk_byte_length)): return False - # offsets unique (no two chunks share a slot) - return int(np.unique(offsets).size) == int(offsets.size) + expected = np.uint64(data_section_start) + np.arange( + offsets.size, dtype=np.uint64 + ) * np.uint64(chunk_byte_length) + return bool(np.array_equal(np.sort(offsets), expected)) def get_chunk_slice(self, chunk_coords: tuple[int, ...]) -> tuple[int, int] | None: localized_chunk = self._localize_chunk(chunk_coords) @@ -1086,8 +1121,12 @@ def _decode_full_shard_bulk_if_uncompressed( dtype/endian view with no reordering. A trailing crc32c is NOT accepted (the bulk path can't verify per-chunk checksums, so crc shards keep the per-chunk path's corruption detection); + - the data type is not structured (the byte-order handling below has no + `Struct` branch); + - `indexer` is an identity full-shard read (`_is_identity_full_read`); - the stored index is dense (every chunk present, equal fixed length, - contiguous) so the data section is a regular grid of chunk payloads. + exactly tiling the data section) so the data section is a regular + grid of chunk payloads. Chunk positions are read from the stored index, so this is correct for any `subchunk_write_order` (morton / lexicographic / colexicographic / @@ -1107,27 +1146,29 @@ def _decode_full_shard_bulk_if_uncompressed( return None ab_codec = self.codecs[0] + # The byte-order handling below lacks the structured-dtype branch of + # `BytesCodec._decode_sync` (which applies `newbyteorder` to multi-byte + # struct fields), so structured dtypes must take the per-chunk path. + if isinstance(shard_spec.dtype, Struct): + return None + chunks_per_shard = self._get_chunks_per_shard(shard_spec) chunk_spec = self._get_chunk_spec(shard_spec) n_chunks = product(chunks_per_shard) if n_chunks == 0: return None - # Only valid for a plain contiguous full-shard read, where each chunk - # lands at its natural grid position. The `sel_shape` check is - # load-bearing: a gather indexer (CoordinateIndexer, from vindex / an - # oindex with an integer-array selection) reorders points and exposes - # `sel_shape`, but its `.shape` is the FLATTENED point count, which can - # equal the shard shape by coincidence (trivially in 1-D). Gating on - # shape alone lets such a selection through, and the bulk path then - # returns the shard in natural order, silently dropping the reordering. - # A contiguous full read (BasicIndexer, or a non-gathering - # OrthogonalIndexer from `arr[:]`) has no `sel_shape` and is served here. - # Anything that gathers must fall through to the per-chunk path so + # Only valid for an identity full-shard read, where each chunk lands at + # its natural grid position. The per-dimension check is load-bearing: + # a gather selection (an `OrthogonalIndexer` with an integer-array or + # boolean dimension, from `arr[perm, :]` / `arr.oindex[...]`, or a + # `CoordinateIndexer` from vindex) can have an output `.shape` equal to + # the shard shape while reordering or duplicating points — serving it + # from the bulk path would return the shard in natural order, silently + # dropping the reordering. Anything that is not a full-slice-per- + # dimension read falls through to the per-chunk path so # chunk_selection / out_selection are honored. - if getattr(indexer, "sel_shape", None) is not None: - return None - if tuple(indexer.shape) != tuple(shard_spec.shape): + if not _is_identity_full_read(indexer, shard_spec.shape): return None chunk_byte_length = self._inner_chunk_byte_length(chunk_spec) @@ -1141,7 +1182,8 @@ def _decode_full_shard_bulk_if_uncompressed( else: index_bytes = shard_bytes[-shard_index_size:] index = self._decode_shard_index_sync(index_bytes, chunks_per_shard) - if not index.is_dense(chunk_byte_length): + data_section_start = shard_index_size if self.index_location == "start" else 0 + if not index.is_dense(chunk_byte_length, data_section_start=data_section_start): return None # --- bulk reconstruct --- @@ -1227,9 +1269,9 @@ def _decode_partial_sync( return None bulk = self._decode_full_shard_bulk_if_uncompressed(shard_bytes, shard_spec, indexer) if bulk is not None: - # The bulk path only fires for a contiguous full-shard read (it - # returns None for any gather indexer that exposes `sel_shape`), - # so the result is already shard-shaped — no reshape needed. + # The bulk path only fires for an identity full-shard read + # (`_is_identity_full_read`), so the result is already + # shard-shaped and in natural order — no reshape needed. return bulk shard_reader = self._shard_reader_from_bytes_sync(shard_bytes, chunks_per_shard) shard_dict: ShardMapping = shard_reader diff --git a/tests/test_codecs/test_sharding_unit.py b/tests/test_codecs/test_sharding_unit.py index 34d468fa05..d8b8242a28 100644 --- a/tests/test_codecs/test_sharding_unit.py +++ b/tests/test_codecs/test_sharding_unit.py @@ -2,12 +2,14 @@ import asyncio from dataclasses import dataclass, replace -from typing import TYPE_CHECKING, Any, cast +from typing import TYPE_CHECKING, Any, Literal, cast from unittest.mock import AsyncMock import numpy as np +import numpy.typing as npt import pytest +import zarr from zarr.abc.codec import ArrayArrayCodec, ArrayBytesCodec from zarr.codecs.bytes import BytesCodec from zarr.codecs.crc32c_ import Crc32cCodec @@ -24,8 +26,10 @@ from zarr.core.buffer import NDBuffer, default_buffer_prototype from zarr.core.buffer.cpu import Buffer from zarr.core.buffer.cpu import NDBuffer as CPUNDBuffer +from zarr.core.chunk_grids import ChunkGrid from zarr.core.config import config from zarr.core.dtype import get_data_type_from_native_dtype +from zarr.core.indexing import BasicIndexer from zarr.storage._common import StorePath from zarr.storage._memory import MemoryStore @@ -910,3 +914,231 @@ async def mock_load_index( kwargs = store_mock.get_ranges.call_args.kwargs assert kwargs["max_gap_bytes"] == 12345 assert kwargs["max_coalesced_bytes"] == 67890 + + +# ============================================================================ +# Bulk full-shard decode: identity-read gating and dtype gating +# ============================================================================ + +_FUSED_PIPELINE = "zarr.core.codec_pipeline.FusedCodecPipeline" + + +def _fused_uncompressed_array( + index_location: Literal["start", "end"], +) -> tuple[Any, npt.NDArray[np.int32]]: + """Sharded `(8, 8)` array whose inner chain is a bare BytesCodec (no crc), + one shard covering the whole array, filled with `arange` data. Callers must + be inside a config context selecting the fused pipeline.""" + shards: ShardsConfigParam = {"shape": (8, 8), "index_location": index_location} + arr = zarr.create_array( + store=MemoryStore(), + shape=(8, 8), + chunks=(2, 2), + shards=shards, + dtype="int32", + compressors=None, + filters=None, + fill_value=0, + config={"write_empty_chunks": True}, + ) + ref = np.arange(64, dtype="int32").reshape(8, 8) + arr[:] = ref + return arr, ref + + +def _spy_on_bulk_decode(monkeypatch: pytest.MonkeyPatch) -> list[bool]: + """Record, per call, whether `_decode_full_shard_bulk_if_uncompressed` + engaged (returned non-None).""" + engaged: list[bool] = [] + orig = ShardingCodec._decode_full_shard_bulk_if_uncompressed + + def spy(self: ShardingCodec, shard_bytes: Any, shard_spec: Any, indexer: Any) -> Any: + result = orig(self, shard_bytes, shard_spec, indexer) + engaged.append(result is not None) + return result + + monkeypatch.setattr(ShardingCodec, "_decode_full_shard_bulk_if_uncompressed", spy) + return engaged + + +_PERM_8 = np.array([7, 2, 5, 0, 3, 6, 1, 4]) +_DUP_8 = np.array([0, 0, 1, 2, 3, 4, 5, 6]) + + +@pytest.mark.parametrize("index_location", ["start", "end"]) +@pytest.mark.parametrize( + ("read", "expected", "expect_bulk"), + [ + pytest.param(lambda a: a[:], lambda r: r, True, id="full-slice"), + pytest.param(lambda a: a[...], lambda r: r, True, id="ellipsis"), + pytest.param( + lambda a: a[_PERM_8, :], lambda r: r[_PERM_8, :], False, id="fancy-permutation" + ), + pytest.param(lambda a: a[_DUP_8, :], lambda r: r[_DUP_8, :], False, id="fancy-duplicates"), + pytest.param( + lambda a: a.oindex[_PERM_8, :], + lambda r: r[_PERM_8, :], + False, + id="oindex-permutation", + ), + pytest.param( + lambda a: a.oindex[_DUP_8, :], lambda r: r[_DUP_8, :], False, id="oindex-duplicates" + ), + pytest.param(lambda a: a[1:7, :], lambda r: r[1:7, :], False, id="subset-slice"), + ], +) +def test_bulk_decode_engagement_and_correctness( + monkeypatch: pytest.MonkeyPatch, + index_location: Literal["start", "end"], + read: Any, + expected: Any, + expect_bulk: bool, +) -> None: + """Under the fused pipeline on an uncompressed crc-free shard, the bulk + whole-shard decode fires exactly for identity full reads — and every read + returns what numpy returns. The engagement assertions keep the correctness + half non-vacuous: a gate that simply disabled the fast path would pass the + value checks but fail here.""" + engaged = _spy_on_bulk_decode(monkeypatch) + with config.set({"codec_pipeline.path": _FUSED_PIPELINE}): + arr, ref = _fused_uncompressed_array(index_location) + engaged.clear() + np.testing.assert_array_equal(read(arr), expected(ref)) + if expect_bulk: + assert len(engaged) > 0, "bulk fast path was never reached for a full read" + assert all(engaged), "bulk fast path did not engage for a full read" + else: + assert not any(engaged), "bulk fast path engaged for a non-identity selection" + + +@pytest.mark.parametrize("index_location", ["start", "end"]) +def test_multi_shard_permutation_read(index_location: Literal["start", "end"]) -> None: + """A row permutation crossing shard boundaries must return permuted data + under the fused pipeline (each shard sees a gather selection whose shape + coincides with the shard shape).""" + shards: ShardsConfigParam = {"shape": (8, 8), "index_location": index_location} + with config.set({"codec_pipeline.path": _FUSED_PIPELINE}): + arr = zarr.create_array( + store=MemoryStore(), + shape=(16, 16), + chunks=(2, 2), + shards=shards, + dtype="int32", + compressors=None, + filters=None, + fill_value=0, + config={"write_empty_chunks": True}, + ) + ref = np.arange(256, dtype="int32").reshape(16, 16) + arr[:] = ref + perm = np.array([9, 3, 12, 0, 15, 6, 10, 1, 14, 5, 8, 2, 13, 7, 11, 4]) + np.testing.assert_array_equal(arr[perm, :8], ref[perm, :8]) + np.testing.assert_array_equal(arr.oindex[perm, :8], ref[perm, :8]) + + +def _dense_shard_blob( + codec: ShardingCodec, data: np.ndarray[Any, np.dtype[Any]], chunk_len: int +) -> Buffer: + """Hand-assemble a dense `index_location="end"` shard blob for 1-D `data`: + natural-order chunk payloads followed by the encoded index.""" + n_chunks = data.shape[0] // chunk_len + chunk_nbytes = chunk_len * data.dtype.itemsize + index = _ShardIndex.create_empty((n_chunks,)) + for i in range(n_chunks): + index.set_chunk_slice((i,), slice(i * chunk_nbytes, (i + 1) * chunk_nbytes)) + index_bytes = codec._encode_shard_index_sync(index) + return Buffer.from_bytes(data.tobytes() + index_bytes.to_bytes()) + + +def _identity_indexer(shape: tuple[int, ...], chunk_shape: tuple[int, ...]) -> BasicIndexer: + return BasicIndexer( + tuple(slice(0, s) for s in shape), + shape=shape, + chunk_grid=ChunkGrid.from_sizes(shape, chunk_shape), + ) + + +def _spec_for(data: np.ndarray[Any, np.dtype[Any]]) -> ArraySpec: + zdt = get_data_type_from_native_dtype(data.dtype) + return ArraySpec( + shape=data.shape, + dtype=zdt, + fill_value=zdt.cast_scalar(0), + config=ArrayConfig(order="C", write_empty_chunks=True), + prototype=default_buffer_prototype(), + ) + + +def test_bulk_decode_declines_structured_dtype() -> None: + """The bulk path has no structured-dtype byte-order handling (the `Struct` + branch of `BytesCodec._decode_sync`), so it must decline structured specs. + The plain-dtype control on an identically constructed blob proves the + decline comes from the dtype gate, not from a malformed blob.""" + codec = ShardingCodec(chunk_shape=(2,), codecs=[BytesCodec(endian="little")]) + + # control: same construction with a plain dtype engages the bulk path + plain = np.arange(4, dtype=" None: + """`is_dense` accepts every layout whose fixed-size payloads exactly tile + the data section, wherever that section starts and in whatever order the + chunks were laid out.""" + chunk_len = 24 + n = int(np.prod(chunks_per_shard)) + slots = np.arange(n) + if layout_order == "reversed": + slots = slots[::-1] + offsets = data_section_start + slots * chunk_len + index = _ShardIndex.create_empty(chunks_per_shard) + for coord, off in zip(np.ndindex(chunks_per_shard), offsets, strict=True): + index.set_chunk_slice(tuple(coord), slice(int(off), int(off) + chunk_len)) + assert index.is_dense(chunk_len, data_section_start=data_section_start) is True + + +def test_shard_index_is_dense_rejects_overlapping_offsets() -> None: + """Unique but overlapping offsets (second payload starts inside the first) + are not dense.""" + chunk_len = 24 + index = _ShardIndex.create_empty((2,)) + index.set_chunk_slice((0,), slice(0, chunk_len)) + index.set_chunk_slice((1,), slice(12, 12 + chunk_len)) + assert index.is_dense(chunk_len, data_section_start=0) is False + + +def test_shard_index_is_dense_rejects_out_of_range_offsets() -> None: + """An offset outside the data section (here: chunk 0 pointing into an + `index_location="start"` index region) is not dense, even though offsets + are unique and non-overlapping.""" + chunk_len = 24 + data_section_start = 16 + index = _ShardIndex.create_empty((2,)) + index.set_chunk_slice((0,), slice(0, chunk_len)) + index.set_chunk_slice( + (1,), slice(data_section_start + chunk_len, data_section_start + 2 * chunk_len) + ) + assert index.is_dense(chunk_len, data_section_start=data_section_start) is False diff --git a/tests/test_fastpath_equivalence.py b/tests/test_fastpath_equivalence.py index 317b8742f1..9a2f782d13 100644 --- a/tests/test_fastpath_equivalence.py +++ b/tests/test_fastpath_equivalence.py @@ -196,34 +196,43 @@ def test_merge_complete_chunk_returns_view_and_write_does_not_mutate_source() -> # --------------------------------------------------------------------------- # Whole-shard bulk decode under arbitrary indexing: the bulk decode only fires -# for a *contiguous full-shard* read, but it is reached through the partial-read -# path (`_decode_partial_sync`), whose only gate is `indexer.shape == -# shard_spec.shape`. A reordering coordinate/orthogonal selection that happens -# to touch every chunk (so the flattened point count equals the shard shape) -# must NOT be served by the bulk path in natural order — it must honor the -# selection. This pins the END-TO-END read (the gate lives in the array read -# path, not in `_decode_full_shard_bulk_if_uncompressed` itself), which -# `test_bulk_shard_decode_equals_general_decode` (BasicIndexer only) cannot -# reach. See the vindex-on-uncompressed-shard corruption bug. +# for an *identity full-shard* read (every dimension a whole-dim step-1 slice), +# but it is reached through the partial-read path (`_decode_partial_sync`) for +# any indexer. A reordering or duplicating coordinate/orthogonal selection can +# have an output shape equal to the shard shape — trivially in 1-D (any +# selection of `shard_len` points), and in >=2-D whenever an axis-0 index array +# has exactly `shard_shape[0]` entries — and must NOT be served by the bulk +# path in natural order; it must honor the selection. This pins the END-TO-END +# read, which `test_bulk_shard_decode_equals_general_decode` (identity +# BasicIndexer only) cannot reach. See the vindex- and +# oindex-on-uncompressed-shard corruption bugs. # --------------------------------------------------------------------------- @st.composite def _uncompressed_shard_index_cases(draw: st.DrawFn) -> dict[str, Any]: - # 1-D is where the trigger is easiest: a CoordinateIndexer's `.shape` is the - # flattened point count, which equals a 1-D shard shape exactly when the - # selection visits `shard_len` points. - chunk = draw(st.integers(1, 4)) - grid = draw(st.integers(1, 4)) - shard_len = chunk * grid + ndim = draw(st.integers(1, 2)) + chunk_shape = tuple(draw(st.integers(1, 4)) for _ in range(ndim)) + grid = tuple(draw(st.integers(1, 4)) for _ in range(ndim)) + shard_shape = tuple(c * g for c, g in zip(chunk_shape, grid, strict=True)) dtype = draw(_DTYPES) - data = draw(npst.arrays(dtype=np.dtype(dtype), shape=(shard_len,))) - perm = draw(st.permutations(list(range(shard_len)))) + data = draw(npst.arrays(dtype=np.dtype(dtype), shape=shard_shape)) + dim0 = shard_shape[0] + # axis-0 index array sized to the dimension: either a permutation + # (reordering, no duplicates) or an arbitrary list (duplicates likely) — + # both keep the output shape equal to the shard shape. + if draw(st.booleans()): + idx = np.array(draw(st.permutations(list(range(dim0)))), dtype=np.intp) + else: + idx = np.array( + draw(st.lists(st.integers(0, dim0 - 1), min_size=dim0, max_size=dim0)), + dtype=np.intp, + ) return { - "chunk": chunk, - "shard_len": shard_len, + "chunk_shape": chunk_shape, + "shard_shape": shard_shape, "data": data, - "perm": np.array(perm), + "idx": idx, "endian": draw(st.sampled_from(["little", "big"])), "index_location": draw(st.sampled_from(["start", "end"])), "subchunk_write_order": draw( @@ -235,33 +244,44 @@ def _uncompressed_shard_index_cases(draw: st.DrawFn) -> dict[str, Any]: @settings(max_examples=200, deadline=None) @given(case=_uncompressed_shard_index_cases()) def test_reordering_read_on_uncompressed_shard_honors_selection(case: dict[str, Any]) -> None: - """A reordering vindex/oindex over a full uncompressed shard must return the - permuted data, not the shard in natural order — under the Fused pipeline - (where the bulk-decode fast path engages) exactly as under numpy.""" - perm = case["perm"] + """A reordering or duplicating fancy/vindex/oindex read over a full + uncompressed shard must return the selected data, not the shard in natural + order — under the Fused pipeline (where the bulk-decode fast path engages) + exactly as under numpy.""" + idx = case["idx"] data = case["data"] + ndim = data.ndim serializer = BytesCodec(endian=case["endian"]) with zarr.config.set({"codec_pipeline.path": "zarr.core.codec_pipeline.FusedCodecPipeline"}): arr = zarr.create_array( store=MemoryStore(), - shape=(case["shard_len"],), - chunks=(case["chunk"],), - shards=(case["shard_len"],), + shape=case["shard_shape"], + chunks=case["chunk_shape"], + shards={"shape": case["shard_shape"], "index_location": case["index_location"]}, dtype=data.dtype, serializer=serializer, compressors=None, filters=None, fill_value=0, ) - arr[:] = data - - # vindex with a full-coverage permutation: flattened point count == - # shard shape, so the buggy gate would mis-classify this as a contiguous - # full-shard read and return data unpermuted. - np.testing.assert_array_equal(arr.vindex[perm], data[perm]) - # oindex with a single reordering index list along the only axis. - np.testing.assert_array_equal(arr.oindex[perm], data[perm]) + arr[...] = data + + # axis-0 index array (rest full slices): an OrthogonalIndexer whose + # output shape equals the shard shape but which reorders/duplicates rows. + rest = (slice(None),) * (ndim - 1) + np.testing.assert_array_equal(arr[(idx, *rest)], data[idx]) + np.testing.assert_array_equal(arr.oindex[(idx, *rest)], data[idx]) + if ndim == 1: + # coordinate selection: flattened point count == shard shape. + np.testing.assert_array_equal(arr.vindex[idx], data[idx]) + else: + # 2-D broadcast index arrays: full-coverage coordinate selection + # whose shape equals the shard shape. + cols = np.arange(case["shard_shape"][1]) + np.testing.assert_array_equal( + arr.vindex[idx[:, None], cols[None, :]], data[idx[:, None], cols[None, :]] + ) # ---------------------------------------------------------------------------