Skip to content

Commit 89bcb4a

Browse files
committed
feat(vortex-edition): session-registered editions validated by unit tests
Editions live on the session like encodings do: EditionSession is a session variable holding the per-session registry of editions and edition inclusions, populated explicitly at initialization time. EditionSession::default() seeds the first-party declarations — core2026.07.0 (a draft until its min_vortex_version is recorded, which is the act of freezing) and the 32 inclusions for the encodings the default writer emits — and any crate can declare further inclusions into a session, so declarations can migrate next to each encoding's registration. Declarations are plain constants (EditionId, Edition, EditionInclusion::new), with correctness enforced by unit tests rather than macros: EditionId::validate and EditionInclusion::validate check identifier and version forms, EditionSession::validate checks a whole registry (undeclared references, drafts-newest chronology, malformed versions, members requiring a release newer than their edition declares), and the validate!(CORE_2026_07_0) macro generates a unit test asserting an edition is well-formed, declared in the default session, and part of a valid registry. Everything else is computed: editions(), encodings_in(edition) (membership is inherited by later editions of the family), and current(family). The intended core2026.07.0 set is pinned by a golden test. Signed-off-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KdR42Svu74NcNzC7XJYwLr
1 parent 39f4abe commit 89bcb4a

7 files changed

Lines changed: 857 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ members = [
88
"vortex-mask",
99
"vortex-utils",
1010
"vortex-session",
11+
"vortex-edition",
1112
"vortex-flatbuffers",
1213
"vortex-metrics",
1314
"vortex-io",
@@ -295,6 +296,7 @@ vortex-compute = { version = "0.1.0", path = "./vortex-compute", default-feature
295296
vortex-datafusion = { version = "0.1.0", path = "./vortex-datafusion", default-features = false }
296297
vortex-datetime-parts = { version = "0.1.0", path = "./encodings/datetime-parts", default-features = false }
297298
vortex-decimal-byte-parts = { version = "0.1.0", path = "encodings/decimal-byte-parts", default-features = false }
299+
vortex-edition = { version = "0.1.0", path = "./vortex-edition", default-features = false }
298300
vortex-error = { version = "0.1.0", path = "./vortex-error", default-features = false }
299301
vortex-fastlanes = { version = "0.1.0", path = "./encodings/fastlanes", default-features = false }
300302
vortex-file = { version = "0.1.0", path = "./vortex-file", default-features = false }

vortex-edition/Cargo.toml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
[package]
2+
name = "vortex-edition"
3+
authors.workspace = true
4+
description = "Definitions of Vortex editions: named, frozen sets of encodings with a read-compatibility guarantee"
5+
edition = { workspace = true }
6+
homepage = { workspace = true }
7+
categories = { workspace = true }
8+
include = { workspace = true }
9+
keywords = { workspace = true }
10+
license = { workspace = true }
11+
readme = { workspace = true }
12+
repository = { workspace = true }
13+
rust-version = { workspace = true }
14+
version = { workspace = true }
15+
16+
[package.metadata.docs.rs]
17+
all-features = true
18+
19+
[lints]
20+
workspace = true
21+
22+
[dependencies]
23+
parking_lot = { workspace = true }
24+
vortex-session = { workspace = true }

vortex-edition/src/definitions.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! The first-party edition and inclusion declarations, seeded into
5+
//! [`EditionSession::default`](crate::EditionSession).
6+
//!
7+
//! Correctness is enforced by unit tests: [`validate!`](crate::validate) covers each
8+
//! declared edition, and the computed set of a frozen edition is pinned by a golden test,
9+
//! so any change to these declarations that alters a frozen set fails CI. New encodings are
10+
//! staged into the newest draft edition. Inclusions are declared centrally here for now;
11+
//! they are expected to migrate next to each encoding's registration.
12+
13+
use crate::Edition;
14+
use crate::EditionId;
15+
use crate::EditionInclusion;
16+
17+
/// The first edition of the `core` family: the canonical encodings — the uncompressed
18+
/// representations every logical type decodes to. A draft until the release shipping it is
19+
/// published and its version is recorded.
20+
pub const CORE_2026_01_0: EditionId = EditionId::new("core", 2026, 1, 0);
21+
22+
/// The second `core` edition: inherits everything in [`CORE_2026_01_0`] and adds the
23+
/// container and compressed encodings the default file writer emits. A draft until the
24+
/// release shipping it is published and its version is recorded.
25+
pub const CORE_2026_07_0: EditionId = EditionId::new("core", 2026, 7, 0);
26+
27+
/// The first-party editions.
28+
pub static DEFAULT_EDITIONS: &[Edition] = &[
29+
Edition {
30+
id: CORE_2026_01_0,
31+
// TODO(editions): freeze by setting this to the first release shipping this
32+
// edition, once it is published.
33+
min_vortex_version: None,
34+
},
35+
Edition {
36+
id: CORE_2026_07_0,
37+
min_vortex_version: None,
38+
},
39+
];
40+
41+
/// The first-party edition inclusions. Members of [`CORE_2026_01_0`] are members of
42+
/// [`CORE_2026_07_0`] (and every later `core` edition) by inheritance — membership is
43+
/// declared once, at the edition an encoding joined in.
44+
pub static DEFAULT_INCLUSIONS: &[EditionInclusion] = &[
45+
EditionInclusion::new("vortex.null", CORE_2026_01_0),
46+
EditionInclusion::new("vortex.bool", CORE_2026_01_0),
47+
EditionInclusion::new("vortex.primitive", CORE_2026_01_0),
48+
EditionInclusion::new("vortex.decimal", CORE_2026_01_0),
49+
EditionInclusion::new("vortex.varbin", CORE_2026_01_0),
50+
EditionInclusion::new("vortex.varbinview", CORE_2026_01_0),
51+
EditionInclusion::new("vortex.list", CORE_2026_01_0),
52+
EditionInclusion::new("vortex.listview", CORE_2026_01_0),
53+
EditionInclusion::new("vortex.fixed_size_list", CORE_2026_01_0),
54+
EditionInclusion::new("vortex.struct", CORE_2026_01_0),
55+
EditionInclusion::new("vortex.variant", CORE_2026_01_0),
56+
EditionInclusion::new("vortex.ext", CORE_2026_01_0),
57+
EditionInclusion::new("vortex.chunked", CORE_2026_07_0),
58+
EditionInclusion::new("vortex.constant", CORE_2026_07_0),
59+
EditionInclusion::new("vortex.dict", CORE_2026_07_0),
60+
EditionInclusion::new("vortex.masked", CORE_2026_07_0),
61+
EditionInclusion::new("vortex.sparse", CORE_2026_07_0),
62+
EditionInclusion::new("vortex.alp", CORE_2026_07_0),
63+
EditionInclusion::new("vortex.alprd", CORE_2026_07_0),
64+
EditionInclusion::new("vortex.bytebool", CORE_2026_07_0),
65+
EditionInclusion::new("vortex.datetimeparts", CORE_2026_07_0),
66+
EditionInclusion::new("vortex.decimal_byte_parts", CORE_2026_07_0),
67+
EditionInclusion::new("vortex.fsst", CORE_2026_07_0),
68+
EditionInclusion::new("vortex.pco", CORE_2026_07_0),
69+
EditionInclusion::new("vortex.runend", CORE_2026_07_0),
70+
EditionInclusion::new("vortex.sequence", CORE_2026_07_0),
71+
EditionInclusion::new("vortex.zigzag", CORE_2026_07_0),
72+
EditionInclusion::new("vortex.zstd", CORE_2026_07_0),
73+
EditionInclusion::new("fastlanes.bitpacked", CORE_2026_07_0),
74+
EditionInclusion::new("fastlanes.delta", CORE_2026_07_0),
75+
EditionInclusion::new("fastlanes.for", CORE_2026_07_0),
76+
EditionInclusion::new("fastlanes.rle", CORE_2026_07_0),
77+
];

vortex-edition/src/lib.rs

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Definitions of Vortex *editions*: named, frozen sets of encodings that a writer may put in
5+
//! a file, carrying a forever read-compatibility guarantee.
6+
//!
7+
//! Editions live on the session, like encodings do: [`EditionSession`] is the session
8+
//! variable holding the edition registry, populated at initialization time. Declarations
9+
//! are plain constants — an [`EditionId`] plus an [`Edition`] record, and one
10+
//! [`EditionInclusion`] per encoding stating that it is a member of an edition *and every
11+
//! later edition of the same family*. Any crate can register declarations into a session,
12+
//! so inclusions can live next to the encoding they describe.
13+
//!
14+
//! An edition is a **draft** until its [`Edition::min_vortex_version`] is recorded —
15+
//! recording it is the act of freezing. The per-edition encoding sets are computed from the
16+
//! registered declarations by [`EditionSession::encodings_in`], and correctness is enforced
17+
//! by unit tests: [`EditionSession::validate`] checks a whole registry, and
18+
//! [`validate!`](crate::validate) generates a test for a single edition.
19+
//!
20+
//! The first-party declarations live in [`definitions`] and seed
21+
//! [`EditionSession::default`]; see the published spec at
22+
//! <https://docs.vortex.dev/specs/editions.html>.
23+
24+
pub mod definitions;
25+
mod session;
26+
#[cfg(test)]
27+
mod tests;
28+
29+
use std::error::Error;
30+
use std::fmt;
31+
use std::fmt::Display;
32+
use std::fmt::Formatter;
33+
34+
pub use session::EditionSession;
35+
pub use session::EditionSessionExt;
36+
37+
/// The identifier of an edition, e.g. `core2026.07.0`.
38+
///
39+
/// The `family` names an independently versioned, additive group of encodings (`core` is the
40+
/// set the default writer emits). The date components record when the edition was frozen and
41+
/// order editions chronologically *within* a family; there is no ordering across families.
42+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43+
pub struct EditionId {
44+
/// The edition family, e.g. `core`.
45+
pub family: &'static str,
46+
/// Year the edition was cut.
47+
pub year: u16,
48+
/// Month the edition was cut.
49+
pub month: u8,
50+
/// Distinguishes editions cut in the same month; normally `0`.
51+
pub version: u8,
52+
}
53+
54+
impl EditionId {
55+
/// Create an edition identifier. Prefer declaring editions with [`edition!`](edition),
56+
/// which parses and validates the textual form at compile time.
57+
pub const fn new(family: &'static str, year: u16, month: u8, version: u8) -> Self {
58+
Self {
59+
family,
60+
year,
61+
month,
62+
version,
63+
}
64+
}
65+
66+
/// Returns true if `self` is the same edition as `other` or an earlier edition of the
67+
/// same family. Editions of different families are never ordered.
68+
pub fn is_at_or_before(&self, other: &EditionId) -> bool {
69+
self.family == other.family
70+
&& (self.year, self.month, self.version) <= (other.year, other.month, other.version)
71+
}
72+
73+
/// Validate the identifier's form: a non-empty lowercase family, a four-digit year,
74+
/// and a month in 01-12. Checked for every declared edition by
75+
/// [`EditionSession::validate`]; the [`validate!`](crate::validate) macro generates a
76+
/// unit test covering a single edition.
77+
pub fn validate(&self) -> Result<(), EditionError> {
78+
if self.family.is_empty() || !self.family.chars().all(|c| c.is_ascii_lowercase()) {
79+
return Err(EditionError::new(format!(
80+
"edition {self} must have a non-empty lowercase family, e.g. `core`"
81+
)));
82+
}
83+
if !(1000..=9999).contains(&self.year) {
84+
return Err(EditionError::new(format!(
85+
"edition {self} must have a four-digit year"
86+
)));
87+
}
88+
if !(1..=12).contains(&self.month) {
89+
return Err(EditionError::new(format!(
90+
"edition {self} must have a month in 01-12"
91+
)));
92+
}
93+
Ok(())
94+
}
95+
}
96+
97+
impl Display for EditionId {
98+
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
99+
write!(
100+
f,
101+
"{}{}.{:02}.{}",
102+
self.family, self.year, self.month, self.version
103+
)
104+
}
105+
}
106+
107+
/// An edition: a named set of encodings with a read-compatibility guarantee, registered with
108+
/// [`EditionSession::declare_edition`]. The set itself is computed from the registered
109+
/// [`EditionInclusion`]s by [`EditionSession::encodings_in`].
110+
#[derive(Clone, Copy, Debug)]
111+
pub struct Edition {
112+
/// The edition identifier. Also carries the freeze date: `core2026.07.0` freezes in
113+
/// 2026-07.
114+
pub id: EditionId,
115+
/// The minimum Vortex version whose reader supports every encoding in this edition.
116+
///
117+
/// Recording this is the act of freezing: an edition with `None` is a **draft** — being
118+
/// assembled, carrying no guarantee, free to change, never the default write target.
119+
/// Validated against the members' [`EditionInclusion::required_vortex_release`] values:
120+
/// no member may require a version newer than the edition declares.
121+
pub min_vortex_version: Option<&'static str>,
122+
}
123+
124+
impl Edition {
125+
/// A draft is an edition whose `min_vortex_version` has not been recorded yet.
126+
pub fn is_draft(&self) -> bool {
127+
self.min_vortex_version.is_none()
128+
}
129+
}
130+
131+
/// Declares that an encoding is a member of an edition — and of every later edition of the
132+
/// same family. Registered with [`EditionSession::declare_inclusion`].
133+
#[derive(Clone, Copy, Debug)]
134+
pub struct EditionInclusion {
135+
/// The encoding ID, e.g. `vortex.alp`. Globally unique across everything an edition can
136+
/// cover: when layout encodings join editions, their IDs must be distinct from array
137+
/// encoding IDs.
138+
pub encoding_id: &'static str,
139+
/// The first edition this encoding is a member of.
140+
pub since: EditionId,
141+
/// The earliest Vortex release able to read and execute this encoding, recorded from
142+
/// evidence (e.g. compat-fixture history). `None` until recorded.
143+
pub required_vortex_release: Option<&'static str>,
144+
}
145+
146+
impl EditionInclusion {
147+
/// Declare that an encoding is a member of `since` and every later edition of the same
148+
/// family.
149+
pub const fn new(encoding_id: &'static str, since: EditionId) -> Self {
150+
Self {
151+
encoding_id,
152+
since,
153+
required_vortex_release: None,
154+
}
155+
}
156+
157+
/// Validate the declaration's form: a lowercase `namespace.name` encoding id and, if
158+
/// recorded, a well-formed `major.minor.patch` release. Checked for every declared
159+
/// inclusion by [`EditionSession::validate`].
160+
pub fn validate(&self) -> Result<(), EditionError> {
161+
let id = self.encoding_id;
162+
let well_formed = !id.starts_with('.')
163+
&& !id.ends_with('.')
164+
&& id.contains('.')
165+
&& id
166+
.chars()
167+
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c));
168+
if !well_formed {
169+
return Err(EditionError::new(format!(
170+
"invalid encoding id {id:?}: expected lowercase `namespace.name`, e.g. \
171+
`vortex.alp`"
172+
)));
173+
}
174+
if let Some(release) = self.required_vortex_release
175+
&& parse_release(release).is_none()
176+
{
177+
return Err(EditionError::new(format!(
178+
"encoding {id} declares malformed required_vortex_release {release:?}"
179+
)));
180+
}
181+
Ok(())
182+
}
183+
}
184+
185+
/// Generates a unit test asserting that the named edition is correctly declared: its
186+
/// identifier is well-formed, it is registered in the default session, and the default
187+
/// session's declarations as a whole validate.
188+
///
189+
/// ```ignore
190+
/// use vortex_edition::definitions::CORE_2026_07_0;
191+
///
192+
/// vortex_edition::validate!(CORE_2026_07_0);
193+
/// ```
194+
#[macro_export]
195+
macro_rules! validate {
196+
($edition:ident) => {
197+
#[expect(non_snake_case, reason = "test module named after the edition const")]
198+
#[cfg(test)]
199+
mod $edition {
200+
#[test]
201+
fn is_correctly_declared() {
202+
let id = super::$edition;
203+
id.validate().unwrap();
204+
205+
let editions = $crate::EditionSession::default();
206+
assert!(
207+
editions.find(&id).is_some(),
208+
"{id} is not declared in the default session",
209+
);
210+
editions.validate().unwrap();
211+
}
212+
}
213+
};
214+
}
215+
216+
/// Parse a `major.minor.patch` release string into a comparable key.
217+
pub(crate) fn parse_release(release: &str) -> Option<Vec<u64>> {
218+
let parts: Vec<u64> = release
219+
.split('.')
220+
.map(|part| part.parse::<u64>().ok())
221+
.collect::<Option<_>>()?;
222+
(parts.len() == 3).then_some(parts)
223+
}
224+
225+
/// Error raised when edition declarations are inconsistent.
226+
#[derive(Debug)]
227+
pub struct EditionError(String);
228+
229+
impl EditionError {
230+
pub(crate) fn new(msg: impl Into<String>) -> Self {
231+
Self(msg.into())
232+
}
233+
}
234+
235+
impl Display for EditionError {
236+
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
237+
f.write_str(&self.0)
238+
}
239+
}
240+
241+
impl Error for EditionError {}

0 commit comments

Comments
 (0)