Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions research/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Type system research

This directory holds research notes about the Vortex type system. The notes are based on `develop`
at `5fc5cb8` (2026-10-17). They live on the `ct/ext-types-research` branch and are not meant to be
merged.

Reviews on 2026-10-22 and 2026-10-23 updated the Decimal notes and [`decisions.md`](decisions.md).
Part 4 of the Decimal notes holds the implementation plan for `Narrow` and the canonical decimal
fix.
The decimal, DecimalByteParts, serialization and versioning code that the notes cite is the same on
`develop` at `dbaf5f8` (2026-10-22).

| Topic | Summary for people | Detail for agents |
| --- | --- | --- |
| Extension types | [`extension-types/`](extension-types/README.md) | [`extension-types/agents/`](extension-types/agents/) |
| Decimal and the type roadmap | [`decimal/`](decimal/README.md) | [`decimal/agents/`](decimal/agents/) |

Each summary document links to the agent documents. The agent documents hold file and line
references, reproductions, fix sketches and file lists.

[`decisions.md`](decisions.md) records the decisions that the maintainer made while reviewing these
notes, and the questions that stay open. The other documents follow it.
76 changes: 76 additions & 0 deletions research/decimal/01-current-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<!--
SPDX-License-Identifier: CC-BY-4.0
SPDX-FileCopyrightText: Copyright the Vortex contributors
-->

# 1. Current state of `Decimal`

All statements are for `develop` at `5fc5cb8`. Section 2 of [`agents/report.md`](agents/report.md)
gives file and line references for each one.

## How it works

- The dtype is `DType::Decimal(DecimalDType { precision, scale }, nullability)`. The precision is
1 to 76.
- A decimal value is stored as an unscaled integer. The value of a row is
`unscaled * 10^(-scale)`.
- `DecimalArray` stores the unscaled integers in one buffer. The metadata field `values_type`
(`I8` to `I256`) gives the width of each value in the buffer.
- The width does not depend on the precision. The array docs allow precision 39 in an `i8` buffer,
and precision 2 in an `i256` buffer.
- The usual width is the smallest integer type that holds every value of the precision. It is `I8`
for precision 1 and 2, and `I256` for precision 39 to 76. These notes call it the canonical
width.

## What is wrong

| Problem | Effect |
| --- | --- |
| One dtype has up to six physical forms | Kernels must handle mixed widths, widen on the fly, or test whether a constant fits the storage. `DecimalArray::patch` panics when the width cannot hold the dtype. Issue [#5820](https://github.com/vortex-data/vortex/issues/5820) collects the fuzzer crashes |
| The compressor chooses the width from the values | `narrowed_decimal` picks the smallest integer type that holds the values, not the width of a lower precision. So an `i32` buffer of a `Decimal(18, 2)` column can hold 2,000,000,000, which needs precision 10 |
| DecimalByteParts decodes to narrow leaves | Since 2025-05, the compressors put narrow values of 64 bits or less into DecimalByteParts. Its decode reuses the buffer of its most significant part, so the canonical result often has a narrow width. The only narrow `vortex.decimal` node that the compressors write is `I128` at precision 39 to 76 |
| The canonical width is a convention, not a contract | 25 call sites compute it. The row codec uses it so that keys "from differently compressed chunks stay memcmp-comparable" |
| Arrays never check the precision | Scalars check it, but arrays do not. So out-of-range values can reach kernels, and a comment in the arithmetic kernel says so |
| The scalar path and the array path disagree | A decimal scalar casts to `f32` and `f16`. The array cast supports only `f64`, so an array cast to `f32` fails with "No CastKernel". A throwaway test confirmed the `f32` case |
| `Decimal` duplicates `Primitive` | It has its own canonical variant (51 dispatch sites), builder, compare, arithmetic and aggregates. Decimal-specific files hold about 7,600 non-test lines |
| DecimalByteParts carries the decimal dtype | It splits wide integers into 64-bit parts, but only decimals can use it |
| The DecimalByteParts cast rule skips a nullability check | For a cast that changes only the nullability, the rule casts the most significant part. It does not first make sure that the change is safe without execution, as other cast rules do with `Validity::trivially_cast_nullability` |
| Unknown enum values decode silently | An unknown `values_type` decodes as `I8`. An unknown protobuf `PType` decodes as `U8`, and Variant metadata stores protobuf dtypes in files |
| Shared code has decimal special cases | Bloom filters, `IN` lists, size statistics, sparse arrays, run-end decoding and the row codec each have a decimal path |
| No 128-bit integer columns | DuckDB `HUGEINT` cannot be imported, although decimal storage already holds 128-bit values |

The silent decoding causes no wrong reads today, because no writer emits unknown values. But it
blocks safe additions to both enums, so a fix is part of the independent changes in
[part 3](03-roadmap.md#independent-changes).

## `Decimal` as a test of the extension design

Each decimal operation is an integer operation plus bookkeeping for the scale. So `Decimal` has the
shape of an extension type over integer storage, and it is a good test of the
[extension design](../extension-types/03-design.md). It needs both kinds of behavior from that
design: operations that derive from the storage, and operations that the type must rewrite.

| Operation | Same precision and scale | Different scales |
| --- | --- | --- |
| `=`, `<`, hash | The same operation on the unscaled integers | Rescale to a common scale first |
| `+`, `-` | An integer operation with overflow and precision checks | Rescale to a common scale first |
| `*`, `/` | A new result type, for example scale `s1 + s2` for `*` | A new result type |
| `min`, `max`, sort | The same operation on the unscaled integers | Not applicable |
| `sum` | An integer sum with a wider result precision | Not applicable |
| Casts | Owned by the type | Owned by the type |

One precision and scale is one dtype. In the terms of the extension design, `=`, `<`, hash, `min`,
`max` and sort within one precision and scale are derives for one dtype. The other operations are
rewrites that the type owns.

Equality and hashing need care. On `develop`, one value can have several physical widths, so the
storage bytes do not identify a value. For this reason, `DecimalValue` converts to `i256` before it
compares or hashes. With one storage width for each precision, a value has exactly one storage form
within one precision and scale. Then storage equality and storage hashing are value equality and
value hashing.

Other pairs are different dtypes. `1.0` at scale 1 is stored as `10`, and `1.00` at scale 2 is
stored as `100`. So a comparison across scales must rescale one side first. Two precisions with
the same scale can have different storage widths, so their comparison must widen one side first.
Under decision X3 in [`decisions.md`](../decisions.md), the engine does this step with a cast to a
common dtype. Vortex then compares only equal dtypes.
Loading
Loading