Skip to content

Delta Store API

delta_store

Physical storage policy for the project's Delta tables.

Why this package exists

contracts owns each table's logical shape and meaning. This package owns its physical layout: parquet writer properties (codec + per-column encodings), compression-friendly sort orders, and significand-precision rounding, plus the write helpers that apply them. Dagster assets stay thin by writing through this package rather than calling write_deltalake with ad-hoc settings — and it becomes impossible to land rows in a table without its storage format applied.

The flagship example is the internal power_forecasts table: ZSTD + DELTA_BINARY_PACKED timestamps + BYTE_STREAM_SPLIT floats + member-adjacent sorting + rounding power_fcst to a 13-bit significand shrank the 403.6M-row development table from 6.33 GB to 0.73 GB.

Contents

  • precision.round_to_significand_bits() — rounds a Float32 expression to a chosen number of significand bits in pure Polars arithmetic (Veltkamp splitting), zeroing the low mantissa bits so BYTE_STREAM_SPLIT + zstd can compress them away. The trick and its preconditions are rigorously documented on the function.
  • power_forecasts — the power_forecasts table's writer properties, sort order, precision policy, and write_power_forecasts().

delta_store.precision

Reduce the significand precision of Float32 columns so parquet compression can work.

Nearly every full-precision Float32 value in a large forecast or weather table is distinct, so the low significand bits are incompressible noise — they defeat every general-purpose codec. Rounding each value to a small number of significand bits zeroes those low bits, after which BYTE_STREAM_SPLIT + zstd compress the column dramatically (see delta_store.power_forecasts for measured numbers), at the cost of a strictly bounded relative error.

The rounding here is pure Polars arithmetic (no bit-twiddling, no numpy round-trip), which works because of a classical floating-point identity — Veltkamp splitting — documented in detail on :func:round_to_significand_bits.

Attributes

FLOAT32_SIGNIFICAND_BITS = 24 module-attribute

IEEE 754 binary32 significand precision p: 23 explicit fraction bits + 1 implicit bit.

Functions:

round_to_significand_bits(expr, *, keep_bits)

Round a Float32 expression to keep_bits significand bits (round-to-nearest).

The result is exactly representable with a keep_bits-bit significand, so the low 24 - keep_bits explicit fraction bits of every finite output are zero — which is what lets BYTE_STREAM_SPLIT + zstd compress the column — and the relative error is bounded by the unit roundoff of a keep_bits-bit format:

|result - x| <= 2**-keep_bits * |x|

(e.g. keep_bits=13 -> max relative error 2^-13 ~= 1.2e-4). Rounding is to nearest, so unlike truncation it introduces no systematic bias toward zero.

How it works — Veltkamp splitting. With s = 24 - keep_bits and the constant C = 2**s + 1, the expression computes, entirely in Float32 round-to-nearest arithmetic (RN)::

c = RN(x * C)  # = RN(x*2^s + x)
result = RN(c - RN(c - x))

Veltkamp's theorem (Veltkamp 1968; Dekker 1971, "A floating-point technique for extending the available precision", Numerische Mathematik 18; Muller et al., Handbook of Floating-Point Arithmetic, 2nd ed., §4.4, Algorithm 4.9 "Split") states that for 2 <= s <= p - 2 and no overflow, both subtractions are exact and result is x rounded to nearest onto p - s = keep_bits significand bits. The intuition: x*C stacks a copy of x shifted s exponent positions above itself; rounding that sum to p bits (forming c) is precisely what discards — with correct rounding — the low s bits of the original x; the two exact subtractions then peel the shifted copy back off, leaving x with its low s significand bits rounded away.

Preconditions — each one is load-bearing:

  • expr must be Float32. The splitting constant is pinned to Float32, but Polars promotes mixed arithmetic: if a Float64 expression is passed, the whole computation runs at p = 53 and silently keeps 53 - s bits instead of keep_bits. (A .cast(pl.Float32) is deliberately not applied here — silently changing the dtype of a Float64 column would be its own trap; callers own their dtypes.)
  • Arithmetic must be evaluated operation-by-operation in IEEE round-to-nearest. Polars (Rust) guarantees this: floats are never reassociated and a*b + c is never contracted into an FMA, either of which would break the exactness of the subtractions.
  • Non-finite and overflow cases are guarded: for |x| > f32_max / C the product c overflows to inf and the naive result would be inf - inf = NaN; for x NaN or ±inf, c is likewise non-finite. Wherever c is non-finite the input value is passed through unchanged (a huge-but-finite value is stored at full precision rather than corrupted; NaN/±inf survive verbatim).
  • Subnormal x (|x| < 2**-126) degrades gracefully: the relative-error bound loosens because precision is already at the absolute floor of the format, but the result is still a faithful nearby value.

Parameters:

Name Type Description Default
expr Expr

A Float32 expression (see the dtype precondition above).

required
keep_bits int

Significand bits to keep, in [2, 22] (the theorem's 2 <= s <= p - 2). Note this counts significand bits — the implicit leading 1 plus explicit fraction bits — so keep_bits=13 keeps 12 explicit fraction bits.

required

Returns:

Type Description
Expr

A Float32 expression: expr rounded to keep_bits significand bits, with

Expr

non-finite and overflowing inputs passed through unchanged.

Source code in packages/delta_store/src/delta_store/precision.py
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
def round_to_significand_bits(expr: pl.Expr, *, keep_bits: int) -> pl.Expr:
    """Round a ``Float32`` expression to ``keep_bits`` significand bits (round-to-nearest).

    The result is exactly representable with a ``keep_bits``-bit significand, so the low
    ``24 - keep_bits`` explicit fraction bits of every finite output are zero — which is what
    lets ``BYTE_STREAM_SPLIT`` + zstd compress the column — and the relative error is bounded by
    the unit roundoff of a ``keep_bits``-bit format:

        |result - x| <= 2**-keep_bits * |x|

    (e.g. ``keep_bits=13`` -> max relative error 2^-13 ~= 1.2e-4). Rounding is to nearest, so
    unlike truncation it introduces no systematic bias toward zero.

    **How it works — Veltkamp splitting.** With ``s = 24 - keep_bits`` and the constant
    ``C = 2**s + 1``, the expression computes, entirely in ``Float32`` round-to-nearest
    arithmetic (``RN``)::

        c = RN(x * C)  # = RN(x*2^s + x)
        result = RN(c - RN(c - x))

    Veltkamp's theorem (Veltkamp 1968; Dekker 1971, "A floating-point technique for extending
    the available precision", *Numerische Mathematik* 18; Muller et al., *Handbook of
    Floating-Point Arithmetic*, 2nd ed., §4.4, Algorithm 4.9 "Split") states that for
    ``2 <= s <= p - 2`` and no overflow, both subtractions are **exact** and ``result`` is ``x``
    rounded to nearest onto ``p - s = keep_bits`` significand bits. The intuition: ``x*C``
    stacks a copy of ``x`` shifted ``s`` exponent positions above itself; rounding that sum to
    ``p`` bits (forming ``c``) is precisely what discards — with correct rounding — the low
    ``s`` bits of the original ``x``; the two exact subtractions then peel the shifted copy back
    off, leaving ``x`` with its low ``s`` significand bits rounded away.

    **Preconditions — each one is load-bearing:**

    - ``expr`` must be ``Float32``. The splitting constant is pinned to ``Float32``, but Polars
      promotes mixed arithmetic: if a ``Float64`` expression is passed, the whole computation
      runs at ``p = 53`` and silently keeps ``53 - s`` bits instead of ``keep_bits``. (A
      ``.cast(pl.Float32)`` is deliberately *not* applied here — silently changing the dtype of
      a ``Float64`` column would be its own trap; callers own their dtypes.)
    - Arithmetic must be evaluated operation-by-operation in IEEE round-to-nearest. Polars
      (Rust) guarantees this: floats are never reassociated and ``a*b + c`` is never contracted
      into an FMA, either of which would break the exactness of the subtractions.
    - Non-finite and overflow cases are guarded: for ``|x| > f32_max / C`` the product ``c``
      overflows to ``inf`` and the naive result would be ``inf - inf = NaN``; for ``x`` NaN or
      ``±inf``, ``c`` is likewise non-finite. Wherever ``c`` is non-finite the input value is
      passed through unchanged (a huge-but-finite value is stored at full precision rather than
      corrupted; ``NaN``/``±inf`` survive verbatim).
    - Subnormal ``x`` (``|x| < 2**-126``) degrades gracefully: the relative-error bound loosens
      because precision is already at the absolute floor of the format, but the result is still
      a faithful nearby value.

    Args:
        expr: A ``Float32`` expression (see the dtype precondition above).
        keep_bits: Significand bits to keep, in ``[2, 22]`` (the theorem's ``2 <= s <= p - 2``).
            Note this counts *significand* bits — the implicit leading 1 plus explicit fraction
            bits — so ``keep_bits=13`` keeps 12 explicit fraction bits.

    Returns:
        A ``Float32`` expression: ``expr`` rounded to ``keep_bits`` significand bits, with
        non-finite and overflowing inputs passed through unchanged.
    """
    shift = FLOAT32_SIGNIFICAND_BITS - keep_bits
    if not 2 <= shift <= FLOAT32_SIGNIFICAND_BITS - 2:
        raise ValueError(
            f"keep_bits must be in [2, {FLOAT32_SIGNIFICAND_BITS - 2}], got {keep_bits}"
        )
    splitter = pl.lit(float(2**shift + 1), dtype=pl.Float32)
    c = expr * splitter
    rounded = c - (c - expr)
    return pl.when(c.is_finite()).then(rounded).otherwise(expr)

delta_store.power_forecasts

Storage policy for the internal power_forecasts Delta table.

Owns everything about how PowerForecast rows are laid out on disk: the parquet writer properties (codec + per-column encodings), the compression-friendly row order, and the power_fcst precision reduction. Callers write through :func:write_power_forecasts so it is impossible to land rows in the table without this format applied.

Measured impact: rewriting the full 403.6M-row development table into this format shrank it from 6.33 GB (delta-rs defaults: SNAPPY, dictionary encoding, unsorted, full precision) to 0.73 GB. See the POWER_FORECASTS_WRITER_PROPERTIES docstring for the per-lever breakdown.

Attributes

POWER_FCST_SIGNIFICAND_BITS = 13 module-attribute

Significand bits kept when storing power_fcst (1 implicit + 12 explicit fraction bits).

Rounding to nearest at 13 significand bits caps the relative error at 2⁻¹³ ≈ 1.2×10⁻⁴ — orders of magnitude below forecast error — and zeroes the 11 low fraction bits, which are otherwise pure entropy that defeats every compression codec (nearly every full-precision power_fcst value is distinct). Mirrors the significand-rounding scheme delta_store.nwp applies to NWP data — but with different writer properties; see that module's docstring for why the choice doesn't transfer between tables. See POWER_FORECASTS_WRITER_PROPERTIES for the measured size impact.

POWER_FORECASTS_SORT_COLS = ('time_series_id', 'power_fcst_init_time', 'valid_time', 'ensemble_member') module-attribute

Within-file row order for power_forecasts writes.

Placing the ~51 ensemble members of one (series, init time, valid time) target on adjacent rows makes power_fcst locally smooth and the timestamp columns stepped sequences — exactly what the BYTE_STREAM_SPLIT and DELTA_BINARY_PACKED encodings in POWER_FORECASTS_WRITER_PROPERTIES need to compress well. Leading with time_series_id also lets parquet row-group statistics prune scans that filter on one series.

POWER_FORECASTS_WRITER_PROPERTIES = WriterProperties(compression='ZSTD', compression_level=3, column_properties={'valid_time': _TIMESTAMP_COLUMN_PROPERTIES, 'power_fcst_init_time': _TIMESTAMP_COLUMN_PROPERTIES, 'nwp_init_time': _TIMESTAMP_COLUMN_PROPERTIES, 'power_fcst': ColumnProperties(encoding='BYTE_STREAM_SPLIT', dictionary_enabled=False)}) module-attribute

Parquet writer settings for the power_forecasts Delta table.

The delta-rs defaults (SNAPPY + dictionary encoding everywhere) leave power_fcst — ~76% of the bytes — essentially uncompressed. Measured on the table's largest single file (18.3M rows, 105 MB): ZSTD-3 alone → 80% of the original size; adding these column encodings plus the POWER_FORECASTS_SORT_COLS row order → 50%; adding the POWER_FCST_SIGNIFICAND_BITS precision reduction → 29%. (That file is the table's least compressible slice — across the full table the format achieved 11.5%.)

Classes

Functions:

write_power_forecasts(forecasts, table_uri, *, replace_partition=None, replace_predicate_extra=None, storage_options=None)

Write PowerForecast rows to the power_forecasts Delta table in its storage format.

Applies the three storage levers before writing: rounds power_fcst to POWER_FCST_SIGNIFICAND_BITS significand bits, sorts rows by POWER_FORECASTS_SORT_COLS, and writes with POWER_FORECASTS_WRITER_PROPERTIES.

The table is partitioned by (experiment_name, fold_id). A multi-chunk materialisation passes replace_partition on its first chunk — overwriting that partition so prior rows are always replaced, even when the first chunk is empty — and None (append) on the rest.

Parameters:

Name Type Description Default
forecasts DataFrame[PowerForecast]

Validated forecast rows. experiment_name / fold_id are String (their PowerForecast dtype), which is exactly what delta-rs needs for Hive-style partition directories — no cast required.

required
table_uri str | Path

Path or URI of the power_forecasts Delta table.

required
replace_partition tuple[str, str] | None

(experiment_name, fold_id) to overwrite, or None to append. Passed explicitly rather than derived from forecasts so an empty first chunk still clears the partition.

None
replace_predicate_extra str | None

An additional AND-ed SQL predicate clause narrowing the overwrite below the (experiment_name, fold_id) partition — e.g. "power_fcst_init_time = '2026-07-04T06:00:00+00:00'" so live_forecasts can replace one 6-hourly slot's rows without wiping the rest of the "live" fold's partition. delta-rs' replaceWhere supports predicates on non-partition columns (confirmed empirically: a datetime.isoformat() literal round-trips correctly against a Timestamp column). Only meaningful alongside replace_partition.

None
storage_options ObjectStoreOptions | None

delta-rs object-store options (credentials/endpoint) for a remote table_uri; None/empty for a local path.

None
Source code in packages/delta_store/src/delta_store/power_forecasts.py
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def write_power_forecasts(
    forecasts: pt.DataFrame[PowerForecast],
    table_uri: str | Path,
    *,
    replace_partition: tuple[str, str] | None = None,
    replace_predicate_extra: str | None = None,
    storage_options: ObjectStoreOptions | None = None,
) -> None:
    """Write ``PowerForecast`` rows to the ``power_forecasts`` Delta table in its storage format.

    Applies the three storage levers before writing: rounds ``power_fcst`` to
    ``POWER_FCST_SIGNIFICAND_BITS`` significand bits, sorts rows by
    ``POWER_FORECASTS_SORT_COLS``, and writes with ``POWER_FORECASTS_WRITER_PROPERTIES``.

    The table is partitioned by ``(experiment_name, fold_id)``. A multi-chunk materialisation
    passes ``replace_partition`` on its first chunk — overwriting that partition so prior rows
    are always replaced, even when the first chunk is empty — and ``None`` (append) on the rest.

    Args:
        forecasts: Validated forecast rows. ``experiment_name`` / ``fold_id`` are ``String``
            (their ``PowerForecast`` dtype), which is exactly what delta-rs needs for Hive-style
            partition directories — no cast required.
        table_uri: Path or URI of the ``power_forecasts`` Delta table.
        replace_partition: ``(experiment_name, fold_id)`` to overwrite, or ``None`` to append.
            Passed explicitly rather than derived from ``forecasts`` so an *empty* first chunk
            still clears the partition.
        replace_predicate_extra: An additional ``AND``-ed SQL predicate clause narrowing the
            overwrite below the ``(experiment_name, fold_id)`` partition — e.g.
            ``"power_fcst_init_time = '2026-07-04T06:00:00+00:00'"`` so ``live_forecasts`` can
            replace one 6-hourly slot's rows without wiping the rest of the ``"live"`` fold's
            partition. delta-rs' ``replaceWhere`` supports predicates on non-partition columns
            (confirmed empirically: a `datetime.isoformat()` literal round-trips correctly
            against a ``Timestamp`` column). Only meaningful alongside ``replace_partition``.
        storage_options: delta-rs object-store options (credentials/endpoint) for a remote
            ``table_uri``; ``None``/empty for a local path.
    """
    prepared = (
        forecasts.with_columns(
            power_fcst=round_to_significand_bits(
                pl.col("power_fcst"), keep_bits=POWER_FCST_SIGNIFICAND_BITS
            )
        )
        .sort(*POWER_FORECASTS_SORT_COLS)
        .to_arrow()
    )
    if replace_partition is not None:
        experiment_name, fold_id = replace_partition
        predicate = f"experiment_name = '{experiment_name}' AND fold_id = '{fold_id}'"
        if replace_predicate_extra is not None:
            predicate = f"{predicate} AND {replace_predicate_extra}"
        write_deltalake(
            table_or_uri=table_uri,
            data=prepared,
            mode="overwrite",
            predicate=predicate,
            partition_by=["experiment_name", "fold_id"],
            writer_properties=POWER_FORECASTS_WRITER_PROPERTIES,
            storage_options=typeddict_to_dict(storage_options),
        )
    else:
        write_deltalake(
            table_or_uri=table_uri,
            data=prepared,
            mode="append",
            partition_by=["experiment_name", "fold_id"],
            writer_properties=POWER_FORECASTS_WRITER_PROPERTIES,
            storage_options=typeddict_to_dict(storage_options),
        )

delta_store.nwp

Storage policy for the nwp Delta table.

Owns everything about how Nwp rows are laid out on disk: the parquet writer properties, the compression-friendly row order, and the significand-precision reduction of the continuous weather variables. Callers write through :func:write_nwp so it is impossible to land rows in the table without this format applied.

Stores plain Float32 + delta_store.precision.round_to_significand_bits — the technique used by delta_store.power_forecasts, but with different writer properties. Measured on real NWP data (9 partitions spread across two years of history): BYTE_STREAM_SPLIT made every continuous column larger, not smaller — the opposite of the power_forecasts result. Working hypothesis: significand rounding collapses NWP values into a small set of repeats (many H3 cells / ensemble members round to the same value), which Parquet's default dictionary+RLE encoding captures directly; BYTE_STREAM_SPLIT scatters that repetition across four separate byte planes and loses more than it gains. power_forecasts's target values have no such repetition (near-continuous ML output), so BYTE_STREAM_SPLIT wins there instead — the two tables need different writer properties. See docs/architecture/overview.md for the measured GB/yr and read-latency numbers.

Attributes

NWP_SIGNIFICAND_BITS = 13 module-attribute

Significand bits kept for every continuous NWP variable (1 implicit + 12 explicit fraction bits) — the same budget as delta_store.power_forecasts.POWER_FCST_SIGNIFICAND_BITS. Caps the relative error at 2⁻¹³ ≈ 1.2×10⁻⁴. Measured max absolute error on real data: ≤0.004 °C for temperature, ≤8 Pa (0.08 hPa) for mean-sea-level pressure — both well inside tolerance (temperature ≤0.25 K, MSL pressure ≤1 hPa).

NWP_SORT_COLS = ('init_time', 'ensemble_member', 'valid_time', 'h3_index') module-attribute

Within-file row order for nwp writes — member before valid_time (the opposite priority from power_forecasts, which sorts member-adjacent for a different reason: there it's about compressing near-duplicate ensemble values; here it's about row-group pruning). Sorting ensemble_member early means each ~1M-row Parquet row group spans only a handful of member values instead of all ~51, so a single-member predicate (the control-member read every training run does) can skip most row groups via min/max stats instead of decoding the whole partition. Measured on a real 29-day/9-cell/control-member read: ~5x faster and ~5x less peak memory than the previous valid_time-first sort, for a ~2% storage cost.

NWP_WRITER_PROPERTIES = WriterProperties(compression='ZSTD', compression_level=3) module-attribute

Deliberately no per-column encoding overrides (no BYTE_STREAM_SPLIT, DELTA_BINARY_PACKED, or disabled dictionary encoding) — see this module's docstring for why that choice, which won for power_forecasts, measures worse here.

Classes

Functions:

write_nwp(nwp, table_uri, storage_options=None)

Append Nwp rows to the nwp Delta table in its storage format.

Rounds every continuous weather variable to NWP_SIGNIFICAND_BITS significand bits, sorts rows by NWP_SORT_COLS, and writes with NWP_WRITER_PROPERTIES. The table is partitioned by (nwp_model_id, init_time), matching Nwp.scan_delta's partition-pruning assumptions; the first write creates the table.

Append-only: each (nwp_model_id, init_time) partition is written exactly once — the daily ecmwf_ens asset downloads one brand-new NWP run per Dagster partition. (No replace_partition option like write_power_forecasts: nothing re-materialises an existing NWP partition today, and a partition-replace predicate on a Timestamp partition column would need its own careful verification — add it only when a caller actually needs it.)

Parameters:

Name Type Description Default
nwp DataFrame[Nwp]

Validated NWP rows for a single (nwp_model_id, init_time) partition.

required
table_uri str | Path

Path or URI of the nwp Delta table.

required
storage_options ObjectStoreOptions | None

delta-rs object-store options (credentials/endpoint) for a remote table_uri; None/empty for a local path.

None
Source code in packages/delta_store/src/delta_store/nwp.py
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
def write_nwp(
    nwp: pt.DataFrame[Nwp],
    table_uri: str | Path,
    storage_options: ObjectStoreOptions | None = None,
) -> None:
    """Append ``Nwp`` rows to the ``nwp`` Delta table in its storage format.

    Rounds every continuous weather variable to ``NWP_SIGNIFICAND_BITS`` significand bits, sorts
    rows by ``NWP_SORT_COLS``, and writes with ``NWP_WRITER_PROPERTIES``. The table is
    partitioned by ``(nwp_model_id, init_time)``, matching ``Nwp.scan_delta``'s
    partition-pruning assumptions; the first write creates the table.

    Append-only: each ``(nwp_model_id, init_time)`` partition is written exactly once — the
    daily ``ecmwf_ens`` asset downloads one brand-new NWP run per Dagster partition. (No
    ``replace_partition`` option like ``write_power_forecasts``: nothing re-materialises an
    existing NWP partition today, and a partition-replace predicate on a ``Timestamp`` partition
    column would need its own careful verification — add it only when a caller actually needs
    it.)

    Args:
        nwp: Validated NWP rows for a single ``(nwp_model_id, init_time)`` partition.
        table_uri: Path or URI of the ``nwp`` Delta table.
        storage_options: delta-rs object-store options (credentials/endpoint) for a remote
            ``table_uri``; ``None``/empty for a local path.
    """
    continuous_vars = sorted(Nwp.continuous_var_names())
    rounded = nwp.with_columns(
        **{
            var: round_to_significand_bits(pl.col(var), keep_bits=NWP_SIGNIFICAND_BITS)
            for var in continuous_vars
        }
    ).sort(*NWP_SORT_COLS)

    # Strip the Patito model before the dict-cast: `nwp_model_id` is declared `Enum` for
    # in-memory type safety, but delta-rs can't store `Enum`/`Categorical` (see the "Delta Lake
    # dictionary-encoded columns" gotcha in CLAUDE.md). A dict-cast on a *model-bearing* frame
    # would silently swallow the mapping and revert other columns to the model's declared dtypes
    # instead — strip first so this is a plain-Polars cast.
    prepared = pl.DataFrame._from_pydf(rounded._df).cast({"nwp_model_id": pl.String}).to_arrow()

    write_deltalake(
        table_or_uri=table_uri,
        data=prepared,
        mode="append",
        partition_by=["nwp_model_id", "init_time"],
        writer_properties=NWP_WRITER_PROPERTIES,
        storage_options=typeddict_to_dict(storage_options),
    )