vortex_edition/lib.rs
1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Definitions of Vortex *editions*: named sets of serialized component IDs. Frozen editions
5//! carry a forever read-compatibility guarantee; draft editions do not.
6//!
7//! Editions live on the session, like encodings do: [`EditionSession`] holds the registered
8//! editions and [`EnabledEditions`] selects which of them a writer may emit. Declarations
9//! are plain constants — an [`EditionId`] plus an [`Edition`] record, and one
10//! [`EditionInclusion`] per member 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 component they describe.
13//!
14//! Every membership is typed by a [`ComponentKind`], and members are resolved one kind at a
15//! time with [`EditionSessionExt::enabled_component_ids`]: the file writer restricts the
16//! arrays, layouts, extension dtypes, and aggregates it writes from separate id sets. Array
17//! memberships name wire IDs rather than in-memory array representations. An array plugin may
18//! serialize one current in-memory representation under several historical IDs. The serialization
19//! context validates the ID chosen by the plugin before writing it. Readers resolve the ID stored
20//! in the file and either deserialize it into the current representation or reject it as unknown.
21//!
22//! An edition is represented as a **draft** until its [`Edition::min_library_version`] is
23//! recorded. A stable edition may freeze in the release that cuts it; once that release version
24//! is known, the field is backfilled to document the freeze. The per-edition member sets are
25//! computed from the registered declarations by [`EditionSession::components_in`], and
26//! correctness is enforced by unit tests: [`EditionSession::validate`] checks a whole registry,
27//! and [`test_harness::validate_edition`] validates one edition's constraints — call it once in
28//! the `#[cfg(test)]` module of each edition definition.
29//!
30//! The first-party edition declarations live in this crate. The public `vortex` crate
31//! re-exports them and registers and enables them on the default session. See the published spec at
32//! <https://docs.vortex.dev/specs/editions.html>.
33
34pub mod declarations;
35mod session;
36pub mod test_harness;
37#[cfg(test)]
38mod tests;
39
40use std::fmt;
41use std::fmt::Debug;
42use std::fmt::Display;
43use std::fmt::Formatter;
44
45pub use declarations::EDITION_DECLARATIONS;
46pub use declarations::EDITION_FAMILIES;
47pub use session::EditionSession;
48pub use session::EditionSessionExt;
49pub use session::EnabledEditions;
50use vortex_error::VortexResult;
51use vortex_error::vortex_bail;
52use vortex_session::registry::Id;
53
54/// The identifier of an edition, e.g. `core2026.07.0`.
55///
56/// The `family` names an independently versioned, additive group of members (`core` is the set
57/// available to the default writer). For `core`, the date components record when the edition
58/// freezes; that date is prospective while the edition is still a draft. Dates order editions
59/// chronologically *within* a family; there is no ordering across families.
60#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
61pub struct EditionId {
62 /// The edition family, e.g. `core`.
63 pub family: &'static str,
64 /// Year in the edition date. For `core`, this is the freeze year.
65 pub year: u16,
66 /// Month in the edition date. For `core`, this is the freeze month.
67 pub month: u8,
68 /// Distinguishes editions with the same family, year, and month; normally `0`.
69 pub version: u8,
70}
71
72impl EditionId {
73 /// Create an edition identifier. Validated by [`EditionId::validate`], which
74 /// [`test_harness::validate_edition`] exercises
75 /// per edition in unit tests.
76 pub const fn new(family: &'static str, year: u16, month: u8, version: u8) -> Self {
77 Self {
78 family,
79 year,
80 month,
81 version,
82 }
83 }
84
85 /// Returns true if `self` is the same edition as `other` or an earlier edition of the
86 /// same family. Editions of different families are never ordered.
87 pub fn is_at_or_before(&self, other: &EditionId) -> bool {
88 self.family == other.family
89 && (self.year, self.month, self.version) <= (other.year, other.month, other.version)
90 }
91
92 /// Validate the identifier's form: a non-empty lowercase family, a four-digit year,
93 /// and a month in 01-12. Checked for every declared edition by
94 /// [`EditionSession::validate`] and per edition by
95 /// [`test_harness::validate_edition`].
96 pub fn validate(&self) -> VortexResult<()> {
97 if self.family.is_empty() || !self.family.chars().all(|c| c.is_ascii_lowercase()) {
98 vortex_bail!("edition {self} must have a non-empty lowercase family, e.g. `core`");
99 }
100 if !(1000..=9999).contains(&self.year) {
101 vortex_bail!("edition {self} must have a four-digit year");
102 }
103 if !(1..=12).contains(&self.month) {
104 vortex_bail!("edition {self} must have a month in 01-12");
105 }
106 Ok(())
107 }
108}
109
110impl Display for EditionId {
111 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
112 write!(
113 f,
114 "{}{}.{:02}.{}",
115 self.family, self.year, self.month, self.version
116 )
117 }
118}
119
120/// A family of editions: an independently versioned, additive group of members, registered
121/// with [`EditionSession::declare_family`].
122///
123/// Every [`EditionId`] names one. Declaring the family is what makes the name real:
124/// [`EditionSession::validate`] rejects an edition whose family was never declared, so a typo
125/// cannot quietly mint a family of one.
126#[derive(Clone, Copy, Debug)]
127pub struct EditionFamily {
128 /// The family name, matching the [`EditionId::family`] of its editions, e.g. `core`.
129 pub name: &'static str,
130 /// The library or project whose releases provide readers for this family's editions.
131 /// [`Edition::min_library_version`] refers to versions of this origin.
132 pub origin: &'static str,
133 /// What the family is for. Exported into the family's record, so a few sentences at
134 /// most: the long form belongs in the published spec.
135 pub doc: &'static str,
136}
137
138impl EditionFamily {
139 /// Validate the family's form: a non-empty lowercase name, origin, and doc. Checked for every
140 /// declared family by [`EditionSession::validate`].
141 pub fn validate(&self) -> VortexResult<()> {
142 if self.name.is_empty() || !self.name.chars().all(|c| c.is_ascii_lowercase()) {
143 vortex_bail!(
144 "edition family {:?} must have a non-empty lowercase name, e.g. `core`",
145 self.name
146 );
147 }
148 if self.origin.trim().is_empty() {
149 vortex_bail!(
150 "edition family {} must name its origin library or project",
151 self.name
152 );
153 }
154 if self.doc.trim().is_empty() {
155 vortex_bail!("edition family {} must document what it is for", self.name);
156 }
157 Ok(())
158 }
159}
160
161/// The kind of member an edition membership covers.
162///
163/// Ids are unique per kind, not globally: a layout named `vortex.flat` and an array named
164/// `vortex.flat` are different members. Every membership records its kind, and the writer
165/// resolves one kind at a time, so the set restricting written arrays never restricts
166/// written layouts. Further kinds (scalar functions, say) can be added the same way.
167#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
168pub enum ComponentKind {
169 /// A serialized array representation, e.g. `vortex.alp`, registered in the session's array
170 /// registry.
171 Array,
172 /// A layout encoding, e.g. `vortex.flat`, registered in the session's layout registry.
173 Layout,
174 /// An extension dtype, e.g. `vortex.timestamp`, registered in the session's dtype registry.
175 DType,
176 /// An aggregate function, e.g. `vortex.min`, written into zone maps and registered in
177 /// the session's aggregate function registry.
178 Aggregate,
179}
180
181impl Display for ComponentKind {
182 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
183 f.write_str(match self {
184 Self::Array => "array",
185 Self::Layout => "layout",
186 Self::DType => "dtype",
187 Self::Aggregate => "aggregate",
188 })
189 }
190}
191
192/// An edition: a named set of serialized components that can acquire a read-compatibility
193/// guarantee, registered with [`EditionSession::declare_edition`].
194/// The set itself is computed from the registered [`EditionInclusion`]s by
195/// [`EditionSession::components_in`].
196#[derive(Clone, Copy, Debug)]
197pub struct Edition {
198 /// The edition identifier. For a `core` edition, its date records when it freezes.
199 pub id: EditionId,
200 /// The minimum version of the edition family's [`EditionFamily::origin`] whose reader
201 /// supports every member of this edition.
202 ///
203 /// A stable edition may freeze in the release that cuts it. Until that release is cut, its
204 /// version is not known and this remains `None`. The version is then backfilled to document
205 /// the already completed freeze and identify the first released reader supporting every
206 /// member. A draft has no recorded read-forever guarantee; that does not imply that its
207 /// behavior is expected to change.
208 /// Validated against the members' [`EditionInclusion::required_vortex_release`] values:
209 /// no member may require a version newer than the edition declares.
210 pub min_library_version: Option<&'static str>,
211}
212
213impl Edition {
214 /// A draft is an edition whose `min_library_version` has not been recorded yet.
215 ///
216 /// This describes the absence of a frozen compatibility guarantee, not necessarily the
217 /// implementation stability of its members.
218 pub fn is_draft(&self) -> bool {
219 self.min_library_version.is_none()
220 }
221}
222
223/// Declares that a serialized component is a member of an edition — and of every later edition of
224/// the same family. Registered with [`EditionSession::declare_inclusion`].
225#[derive(Clone, Copy, Debug)]
226pub struct EditionInclusion {
227 /// What the membership covers. Ids are unique per kind, so this is part of the
228 /// member's identity, not a label.
229 pub kind: ComponentKind,
230 /// The interned component id, e.g. `vortex.alp`.
231 pub component_id: Id,
232 /// The first edition this component is a member of.
233 pub since: EditionId,
234 /// The earliest Vortex release supporting this member, recorded from evidence (e.g.
235 /// compat-fixture history for serialized components). `None` until recorded.
236 pub required_vortex_release: Option<&'static str>,
237}
238
239/// A source of a component id for edition declarations.
240///
241/// Implemented for raw id strings (`"vortex.alp"`) and interned [`Id`]s here; encoding
242/// vtables implement it where they are defined, so a declaration can name the vtable
243/// (`&Primitive`) instead of spelling its id. The id alone does not say what kind of
244/// component it names — [`EditionMember`] pairs it with a [`ComponentKind`].
245pub trait AsComponentId: Debug + Send + Sync {
246 /// The interned component id.
247 fn component_id(&self) -> Id;
248}
249
250impl AsComponentId for str {
251 #[expect(
252 clippy::disallowed_methods,
253 reason = "interning a dynamic component id at declaration time"
254 )]
255 fn component_id(&self) -> Id {
256 Id::new(self)
257 }
258}
259
260impl AsComponentId for Id {
261 fn component_id(&self) -> Id {
262 *self
263 }
264}
265
266// `str` is unsized and cannot be a trait object, so declaration blocks name components as
267// `&"vortex.alp"` through this impl.
268impl AsComponentId for &'static str {
269 fn component_id(&self) -> Id {
270 (**self).component_id()
271 }
272}
273
274/// A member that joins an edition, named by id string or vtable and tagged with its kind.
275/// Built with the per-kind constructors, so a declaration reads
276/// as `EditionMember::array(&"vortex.alp")`.
277#[derive(Clone, Copy, Debug)]
278pub struct EditionMember {
279 /// What kind of member this is.
280 pub kind: ComponentKind,
281 /// The member, named by id string or by vtable.
282 pub component: &'static dyn AsComponentId,
283}
284
285impl EditionMember {
286 /// An array encoding member, e.g. `vortex.alp`.
287 pub const fn array(component: &'static dyn AsComponentId) -> Self {
288 Self {
289 kind: ComponentKind::Array,
290 component,
291 }
292 }
293
294 /// A layout member, e.g. `vortex.flat`.
295 pub const fn layout(component: &'static dyn AsComponentId) -> Self {
296 Self {
297 kind: ComponentKind::Layout,
298 component,
299 }
300 }
301
302 /// An extension dtype member, e.g. `vortex.timestamp`.
303 pub const fn dtype(component: &'static dyn AsComponentId) -> Self {
304 Self {
305 kind: ComponentKind::DType,
306 component,
307 }
308 }
309
310 /// An aggregate function member, e.g. `vortex.min`.
311 pub const fn aggregate(component: &'static dyn AsComponentId) -> Self {
312 Self {
313 kind: ComponentKind::Aggregate,
314 component,
315 }
316 }
317}
318
319/// Declares an edition together with its new members in one block. Registered with
320/// [`EditionSession::declare`], which derives each entry's membership (`since` = the declared
321/// edition) from the block structure.
322#[derive(Clone, Copy, Debug)]
323pub struct EditionDeclaration {
324 /// The edition being declared.
325 pub edition: Edition,
326 /// The members that join the family at this edition, each tagged with its [`ComponentKind`].
327 /// Earlier entries are inherited and never restated.
328 pub added: &'static [EditionMember],
329}
330
331impl EditionInclusion {
332 /// Declare that a component of `kind` is a member of `since` and every later edition of
333 /// the same family. The component can be named by id string or by vtable.
334 pub fn new<C: AsComponentId + ?Sized>(
335 kind: ComponentKind,
336 component: &C,
337 since: EditionId,
338 ) -> Self {
339 Self {
340 kind,
341 component_id: component.component_id(),
342 since,
343 required_vortex_release: None,
344 }
345 }
346
347 /// Declare that an array encoding is a member of `since` and every later edition of the
348 /// same family.
349 pub fn array<C: AsComponentId + ?Sized>(encoding: &C, since: EditionId) -> Self {
350 Self::new(ComponentKind::Array, encoding, since)
351 }
352
353 /// Declare that an extension dtype is a member of `since` and every later edition of the
354 /// same family.
355 pub fn dtype<C: AsComponentId + ?Sized>(dtype: &C, since: EditionId) -> Self {
356 Self::new(ComponentKind::DType, dtype, since)
357 }
358
359 /// Validate the declaration's form: a lowercase `namespace.name` component id and, if
360 /// recorded, a well-formed `major.minor.patch` release. Checked for every declared
361 /// inclusion by [`EditionSession::validate`].
362 pub fn validate(&self) -> VortexResult<()> {
363 let id = self.component_id.as_str();
364 let well_formed = !id.starts_with('.')
365 && !id.ends_with('.')
366 && id.contains('.')
367 && id
368 .chars()
369 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || "._-".contains(c));
370 if !well_formed {
371 vortex_bail!(
372 "invalid {} id {id:?}: expected lowercase `namespace.name`, e.g. `vortex.alp`",
373 self.kind
374 );
375 }
376 if let Some(release) = self.required_vortex_release
377 && parse_release(release).is_none()
378 {
379 vortex_bail!(
380 "{} {id} declares malformed required_vortex_release {release:?}",
381 self.kind
382 );
383 }
384 Ok(())
385 }
386}
387
388/// Parse a `major.minor.patch` release string into a comparable key.
389pub(crate) fn parse_release(release: &str) -> Option<Vec<u64>> {
390 let parts: Vec<u64> = release
391 .split('.')
392 .map(|part| part.parse::<u64>().ok())
393 .collect::<Option<_>>()?;
394 (parts.len() == 3).then_some(parts)
395}