Repository navigation
Description
This is a tracking issue for letting extension types opt into storage behavior like equality and ordering in a concise manner, similar to Rust's #[derive(...)]. The claims live on ExtVTable and DType in vortex-array/src/dtype/.
- Subissue of Epic: Harden Type System #10730 and Epic: Extension Types #7683.
- Builds on Tracking Issue: Fix Extension Types #11114, which makes extension types opaque by default.
Motivation
Once extension types are opaque (#11114), every semantic (not structural, see the next section) operation on them is rejected unless the type opts in. But most types often want common behaviors from their storage. For example, timestamps should compare and sort like their i64 storage, and UUIDs should compare equality by their bytes. Without a concise opt-in, every extension type would need its own handwritten rule for every operation that relies on equality or ordering, which would likely just be boilerplate, pushing stuff down into the storage.
Today, the extension compare kernel compares storage for every extension type (which is wrong for JSON), while is_sorted and min_max only run on storage for the temporal types and Uuid, through hand-written registrations (#10566, #10616).
Design
Semantic vs structural operations
Generally speaking, there are 2 kinds of operations in Vortex. Structural operations move or select values without reading them, like slice, take, filter, and zip. Semantic operations read values, so their result depends on what the values mean, like =, <, min, hashing, and casts.
Structural operations are the only ones that can "always work" on extension types. We can always filter an extension array, but we can't run some function add_1 on it, because we don't know anything about the semantics of its values (even if the storage type is an integer).
So loosely speaking, we can call operations that depend on the semantics of the values "semantic" operations, and things that operate over the structure of arrays / columns "structural" operations. Generally speaking, we can push down structural operations but not semantic ones.
Note that there are a few functions, like replace_time_zone or the spatial functions, that implement the semantics of specific extension types themselves, so we are not concerned with those.
Extension "claims"
There are cases where we can forward semantic operations to the storage, but only if we know something about the relationship between the extension type and its storage type.
For most extension types, 2 values are equal if and only if their storage values are equal. This isn't true when a type considers 2 different storage values to be equal (think JSON, or integers modulo some number). Similarly, for many types the order of the values is exactly the order of their storage values (which implies equality). The datetime types have both properties, so we can push equality and ordering down into their integer storage.
So we want extension types to be able to "claim" these properties, similar to Rust's #[derive(...)]. I propose something like this (which can evolve in the future):
// In the future, this might become an open set instead of hardcoded.
#[non_exhaustive]
pub enum Claim { Eq, Ord }
pub trait ExtVTable {
fn claims(dtype: &ExtDType<Self>) -> &'static [Claim] { &[] }
}
impl DType {
pub fn holds(&self, claim: Claim) -> bool;
}Note that a claim only covers values of 1 dtype, so Timestamp(ms) and Timestamp(ns) never compare even though both claim Ord. Claims are listed by hand rather than derived from which kernels exist, and ExtDType::try_new checks that the declared storage dtype actually holds them. Claims beyond Eq and Ord (for example, for text functions on Json) and claims defined outside of Vortex are future work.
A more formal definition of a claim
A claim is a promise that an extension dtype E makes about its storage dtype S: some kind of operation gives the same answer on the values of E as it does on their storage values, in both directions. For any 2 non-null values a and b of E with storage values s(a) and s(b):
Eqmeansa == bif and only ifs(a) == s(b). In other words, every value has exactly 1 storage representation.Ordmeansa < bif and only ifs(a) < s(b), using the order that Vortex already defines onS(including the total order on floats). This impliesEq.
Claims for the existing extension types
| Type | Eq |
Ord |
|---|---|---|
Date, Time, Timestamp, Duration |
Yes | Yes |
Uuid (byte order) |
Yes | Yes |
Vector, FixedShapeTensor |
Yes | No |
Json, WKB, geometry types, Interval, unknown types |
No | No |
Using claims
Each scalar function and aggregate also declares how it uses extension operands. #11114 adds this declaration without Storage, and this issue adds Storage:
pub enum ExtensionUse {
None,
Structural,
Specific,
Storage { claims: Vec<Option<Claim>>, result: ResultMapping },
}A Storage function names the claim that each argument needs and how its result gets the extension dtype back. When the claims hold, Vortex rewrites the call to run on storage (ts_a < ts_b becomes ext_storage(ts_a) < ext_storage(ts_b)), so the existing kernels, statistics, and pruning apply for free. Otherwise, the call is rejected.
Storage carries the result mapping, so every function that runs on storage has to give one:
pub enum ResultMapping {
Rewrap(ExtDTypeRef),
Keep,
Fields(Vec<ResultMapping>),
Custom(Arc<dyn CustomMapping>),
}Rewrapwraps the storage result in the given extension dtype. For example,minneeds to answerRewrapwith its input dtype, since the minimum of timestamps is also a timestamp. The target is a dtype and not an argument index, so a result that is part of an input works too: a hypotheticallist_max(List<Timestamp>)answersRewrap(Timestamp). Note that I think there is some work to be done with nested paths here, but I think this idea still works.Keepleaves the result as it is. Comparisons andis_sortedanswerKeep, since they returnBoolno matter what.Fieldsgives one mapping for each field of a struct result.min_maxanswersFieldswith 2Rewraps.Customlets the function map the result itself, for results that the other 3 can't describe. For example,minoverList<Timestamp>returns aList<Timestamp>, which isn't an extension dtype.
Steps
- Add
Claim,DType::holds, andExtVTable::claims, checked when a dtype is built (needs the storage declaration from Tracking Issue: Fix Extension Types #11114) - Give every extension type in the repository its claims
- Conformance checks and reference test types for claims
- Run a function on storage when its claims hold, with
ExtensionUse::Storage,ResultMapping, and the generic storage rewrite (needs the typing check from Tracking Issue: Fix Extension Types #11114) - Replace the hardcoded opt-ins of Tracking Issue: Fix Extension Types #11114 with claims for compare, the aggregates,
list_contains, the row encoding, statistics, pruning, and engine pushdown - Document claims for extension type authors
Until then, #11114 hardcodes the opt-ins for the extension types in the repository. Its lists match the claims of these types, so each switch to claims allows and rejects the same calls.
PR plan (agents keep this list up to date)
Each line is one PR. The dependency graph of the implementation plan uses the same IDs, and the decisions record the design (X11 to X19, and X35). When a PR opens, add its link, and when it merges, tick its box.
- CL1
ClaimwithClaim::implies,DType::holds, andExtVTable::claimswith an erased forwarder onExtDTypeRef, checked inExtDType::try_new(X13 to X15). Needs: SD (Tracking Issue: Fix Extension Types #11114) - CL2 Claims for every extension type in the repository (X16). Needs: CL1
- CL3 Conformance checks for claims, and the reference test types: an opaque type, a decimal-like type, and a refined type. Needs: CL1. The test of the opaque type needs TY (Tracking Issue: Fix Extension Types #11114), and the differential test of the decimal-like type needs SP3 to SP5 and CD8a (Tracking Issue: Fix Canonical Decimal #10701)
- SP1
ExtensionUse::StorageandResultMapping, with theRewrapguard and the shared wrap and unwrap code (X17, X19). Needs: CL1, and FD and NU (Tracking Issue: Fix Extension Types #11114) - SP2 Typing accepts a
Storagecall when its claims hold, and the generic storage rewrite withext_storage_deep. Needs: SP1, and TY (Tracking Issue: Fix Extension Types #11114) - SP3 Compare and
betweenthrough the claims, without the extension compare kernel, plus the rule that keeps theDateTimePartscompare kernel (X32). Needs: CL2, SP2 - SP4 Aggregates through the claims: the default storage path in aggregate binding (X21), no composite IDs or
register_storage_ordered, and DuckDB pushdown through the claims. Needs: CL2, SP2. Coordinate with Move aggregates out of vortex-array into vortex-aggregates #10769 - SP5
list_containsand the row encoding through the claims. Needs: CL2, SP2 - SP6 Statistics readers and pruning through the claims. Needs: SP4, and ST1 (Tracking Issue: Fix Extension Types #11114)
- SP7 Engine pushdown through the claims. Needs: CL2
Activity
Name the nodes of the extension dependency graph after the PR plan of #…
Move the claims and storage paths of the extension plan to #11115