Skip to content

Commit a4f07a8

Browse files
committed
feat(vortex-edition): session-registered editions declared in the vortex facade
vortex-edition provides the editions machinery: the types (EditionId, Edition, EditionInclusion, EditionDeclaration), the EditionSession session variable holding the per-session registry (starts empty; clones share the registry like other session registries), computation (editions(), encodings_in() with membership inherited by later editions of the family, current()), validation (identifier/version forms via EditionId::validate and EditionInclusion::validate, plus whole-registry checks: undeclared references, drafts-newest chronology, members requiring a release newer than their edition declares), and the validate!(session, EDITION) macro generating a per-edition unit test. Encoding ids use the session's intern pool (registry::Id, the same identifier space as ArrayId), and declaration blocks name encodings via the AsEncodingId trait — an id string today, an encoding vtable once vortex-array implements it. The actual declarations live in the public vortex crate (vortex::editions): two core editions declared block-wise with the encodings each adds — core2026.01.0 with the 12 canonical encodings and core2026.07.0 inheriting them plus the 20 container/compressed encodings the default writer emits. Both are drafts until their min_vortex_version is recorded, which is the act of freezing. register_default_editions seeds them into the default session, golden tests pin both sets, and transitivity/consistency tests cover inheritance and the one-inclusion-per-encoding invariant. 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 a4f07a8

9 files changed

Lines changed: 1021 additions & 0 deletions

File tree

Cargo.lock

Lines changed: 9 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/lib.rs

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
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+
//! This crate defines only the types and the session variable; the first-party edition
21+
//! declarations live in the public `vortex` crate (`vortex::editions`), which seeds them
22+
//! into the default session. See the published spec at
23+
//! <https://docs.vortex.dev/specs/editions.html>.
24+
25+
mod session;
26+
#[cfg(test)]
27+
mod tests;
28+
29+
use std::error::Error;
30+
use std::fmt;
31+
use std::fmt::Debug;
32+
use std::fmt::Display;
33+
use std::fmt::Formatter;
34+
35+
pub use session::EditionSession;
36+
pub use session::EditionSessionExt;
37+
use vortex_session::registry::Id;
38+
39+
/// The identifier of an edition, e.g. `core2026.07.0`.
40+
///
41+
/// The `family` names an independently versioned, additive group of encodings (`core` is the
42+
/// set the default writer emits). The date components record when the edition was frozen and
43+
/// order editions chronologically *within* a family; there is no ordering across families.
44+
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45+
pub struct EditionId {
46+
/// The edition family, e.g. `core`.
47+
pub family: &'static str,
48+
/// Year the edition was cut.
49+
pub year: u16,
50+
/// Month the edition was cut.
51+
pub month: u8,
52+
/// Distinguishes editions cut in the same month; normally `0`.
53+
pub version: u8,
54+
}
55+
56+
impl EditionId {
57+
/// Create an edition identifier. Validated by [`EditionId::validate`], which the
58+
/// [`validate!`](crate::validate) macro exercises per edition in unit tests.
59+
pub const fn new(family: &'static str, year: u16, month: u8, version: u8) -> Self {
60+
Self {
61+
family,
62+
year,
63+
month,
64+
version,
65+
}
66+
}
67+
68+
/// Returns true if `self` is the same edition as `other` or an earlier edition of the
69+
/// same family. Editions of different families are never ordered.
70+
pub fn is_at_or_before(&self, other: &EditionId) -> bool {
71+
self.family == other.family
72+
&& (self.year, self.month, self.version) <= (other.year, other.month, other.version)
73+
}
74+
75+
/// Validate the identifier's form: a non-empty lowercase family, a four-digit year,
76+
/// and a month in 01-12. Checked for every declared edition by
77+
/// [`EditionSession::validate`]; the [`validate!`](crate::validate) macro generates a
78+
/// unit test covering a single edition.
79+
pub fn validate(&self) -> Result<(), EditionError> {
80+
if self.family.is_empty() || !self.family.chars().all(|c| c.is_ascii_lowercase()) {
81+
return Err(EditionError::new(format!(
82+
"edition {self} must have a non-empty lowercase family, e.g. `core`"
83+
)));
84+
}
85+
if !(1000..=9999).contains(&self.year) {
86+
return Err(EditionError::new(format!(
87+
"edition {self} must have a four-digit year"
88+
)));
89+
}
90+
if !(1..=12).contains(&self.month) {
91+
return Err(EditionError::new(format!(
92+
"edition {self} must have a month in 01-12"
93+
)));
94+
}
95+
Ok(())
96+
}
97+
}
98+
99+
impl Display for EditionId {
100+
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
101+
write!(
102+
f,
103+
"{}{}.{:02}.{}",
104+
self.family, self.year, self.month, self.version
105+
)
106+
}
107+
}
108+
109+
/// An edition: a named set of encodings with a read-compatibility guarantee, registered with
110+
/// [`EditionSession::declare_edition`]. The set itself is computed from the registered
111+
/// [`EditionInclusion`]s by [`EditionSession::encodings_in`].
112+
#[derive(Clone, Copy, Debug)]
113+
pub struct Edition {
114+
/// The edition identifier. Also carries the freeze date: `core2026.07.0` freezes in
115+
/// 2026-07.
116+
pub id: EditionId,
117+
/// The minimum Vortex version whose reader supports every encoding in this edition.
118+
///
119+
/// Recording this is the act of freezing: an edition with `None` is a **draft** — being
120+
/// assembled, carrying no guarantee, free to change, never the default write target.
121+
/// Validated against the members' [`EditionInclusion::required_vortex_release`] values:
122+
/// no member may require a version newer than the edition declares.
123+
pub min_vortex_version: Option<&'static str>,
124+
}
125+
126+
impl Edition {
127+
/// A draft is an edition whose `min_vortex_version` has not been recorded yet.
128+
pub fn is_draft(&self) -> bool {
129+
self.min_vortex_version.is_none()
130+
}
131+
}
132+
133+
/// Declares that an encoding is a member of an edition — and of every later edition of the
134+
/// same family. Registered with [`EditionSession::declare_inclusion`].
135+
#[derive(Clone, Copy, Debug)]
136+
pub struct EditionInclusion {
137+
/// The interned encoding id, e.g. `vortex.alp`. Globally unique across everything an
138+
/// edition can cover: when layout encodings join editions, their ids must be distinct
139+
/// from array encoding ids.
140+
pub encoding_id: Id,
141+
/// The first edition this encoding is a member of.
142+
pub since: EditionId,
143+
/// The earliest Vortex release able to read and execute this encoding, recorded from
144+
/// evidence (e.g. compat-fixture history). `None` until recorded.
145+
pub required_vortex_release: Option<&'static str>,
146+
}
147+
148+
/// A source of an encoding id for edition declarations.
149+
///
150+
/// Implemented for raw id strings (`"vortex.alp"`) and interned [`Id`]s here; encoding
151+
/// vtables implement it where they are defined, so a declaration can name the vtable
152+
/// (`&Primitive`) instead of spelling its id.
153+
pub trait AsEncodingId: Debug + Send + Sync {
154+
/// The interned encoding id.
155+
fn encoding_id(&self) -> Id;
156+
}
157+
158+
impl AsEncodingId for str {
159+
#[expect(
160+
clippy::disallowed_methods,
161+
reason = "interning a dynamic encoding id at declaration time"
162+
)]
163+
fn encoding_id(&self) -> Id {
164+
Id::new(self)
165+
}
166+
}
167+
168+
impl AsEncodingId for Id {
169+
fn encoding_id(&self) -> Id {
170+
*self
171+
}
172+
}
173+
174+
// `str` is unsized and cannot be a trait object, so declaration blocks (slices of
175+
// `&dyn AsEncodingId`) name encodings as `&"vortex.alp"` through this impl.
176+
impl AsEncodingId for &'static str {
177+
fn encoding_id(&self) -> Id {
178+
(**self).encoding_id()
179+
}
180+
}
181+
182+
/// Declares an edition together with the encodings that join the family at it, in one
183+
/// block. Registered with [`EditionSession::declare`], which derives each encoding's
184+
/// membership (`since` = the declared edition) from the block structure.
185+
#[derive(Clone, Copy, Debug)]
186+
pub struct EditionDeclaration {
187+
/// The edition being declared.
188+
pub edition: Edition,
189+
/// The encodings that join the family at this edition, named by id string or by
190+
/// vtable. Members of earlier editions are inherited and never restated.
191+
pub added: &'static [&'static dyn AsEncodingId],
192+
}
193+
194+
impl EditionInclusion {
195+
/// Declare that an encoding is a member of `since` and every later edition of the same
196+
/// family. The encoding can be named by id string or by vtable.
197+
pub fn new<E: AsEncodingId + ?Sized>(encoding: &E, since: EditionId) -> Self {
198+
Self {
199+
encoding_id: encoding.encoding_id(),
200+
since,
201+
required_vortex_release: None,
202+
}
203+
}
204+
205+
/// Validate the declaration's form: a lowercase `namespace.name` encoding id and, if
206+
/// recorded, a well-formed `major.minor.patch` release. Checked for every declared
207+
/// inclusion by [`EditionSession::validate`].
208+
pub fn validate(&self) -> Result<(), EditionError> {
209+
let id = self.encoding_id.as_str();
210+
let well_formed = !id.starts_with('.')
211+
&& !id.ends_with('.')
212+
&& id.contains('.')
213+
&& id
214+
.chars()
215+
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c));
216+
if !well_formed {
217+
return Err(EditionError::new(format!(
218+
"invalid encoding id {id:?}: expected lowercase `namespace.name`, e.g. \
219+
`vortex.alp`"
220+
)));
221+
}
222+
if let Some(release) = self.required_vortex_release
223+
&& parse_release(release).is_none()
224+
{
225+
return Err(EditionError::new(format!(
226+
"encoding {id} declares malformed required_vortex_release {release:?}"
227+
)));
228+
}
229+
Ok(())
230+
}
231+
}
232+
233+
/// Generates a unit test asserting that the named edition is correctly declared: its
234+
/// identifier is well-formed, it is registered in the given [`EditionSession`], and that
235+
/// session's declarations as a whole validate.
236+
///
237+
/// The first argument is an expression producing the session to check, evaluated with the
238+
/// invoking module's names in scope:
239+
///
240+
/// ```ignore
241+
/// vortex_edition::validate!(edition_session(), CORE_2026_07_0);
242+
/// ```
243+
#[macro_export]
244+
macro_rules! validate {
245+
($editions:expr, $edition:ident) => {
246+
#[expect(non_snake_case, reason = "test module named after the edition const")]
247+
#[cfg(test)]
248+
mod $edition {
249+
#[test]
250+
fn is_correctly_declared() {
251+
use super::*;
252+
253+
let id = $edition;
254+
id.validate().unwrap();
255+
256+
let editions: $crate::EditionSession = $editions;
257+
assert!(
258+
editions.find(&id).is_some(),
259+
"{id} is not declared in the session",
260+
);
261+
editions.validate().unwrap();
262+
}
263+
}
264+
};
265+
}
266+
267+
/// Parse a `major.minor.patch` release string into a comparable key.
268+
pub(crate) fn parse_release(release: &str) -> Option<Vec<u64>> {
269+
let parts: Vec<u64> = release
270+
.split('.')
271+
.map(|part| part.parse::<u64>().ok())
272+
.collect::<Option<_>>()?;
273+
(parts.len() == 3).then_some(parts)
274+
}
275+
276+
/// Error raised when edition declarations are inconsistent.
277+
#[derive(Debug)]
278+
pub struct EditionError(String);
279+
280+
impl EditionError {
281+
pub(crate) fn new(msg: impl Into<String>) -> Self {
282+
Self(msg.into())
283+
}
284+
}
285+
286+
impl Display for EditionError {
287+
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
288+
f.write_str(&self.0)
289+
}
290+
}
291+
292+
impl Error for EditionError {}

0 commit comments

Comments
 (0)