Skip to main content

ipfrs_transport/
schema_registry.rs

1//! Arrow IPC schema evolution support for TensorSwap protocol.
2//!
3//! This module provides:
4//! - [`SchemaVersion`]: a versioned identifier for an Arrow schema.
5//! - [`SchemaRegistry`]: a registry that tracks multiple named schema versions
6//!   and validates compatibility when schemas evolve.
7//! - [`SchemaEvolutionFrame`]: the wire frame used to signal a schema change
8//!   mid-stream inside a TensorSwap session.
9//! - [`EvolutionStrategy`]: the policy that governs how schemas may evolve.
10//!
11//! # Compatibility rules
12//!
13//! Two schema versions are *compatible* (reader can decode writer data) when:
14//! 1. They share the same name.
15//! 2. Every field present in the *writer* schema exists with the same name and
16//!    data type in the *reader* schema.
17//! 3. New fields added by the reader (absent in the writer) must be nullable so
18//!    that missing values can be represented as null.
19
20use arrow::datatypes::{DataType, Field, Schema};
21use std::collections::HashMap;
22use std::sync::Arc;
23
24// ---------------------------------------------------------------------------
25// FNV-1a implementation (pure Rust, no external dep)
26// ---------------------------------------------------------------------------
27
28/// Compute a 64-bit FNV-1a fingerprint over an arbitrary byte slice.
29fn fnv1a_64(data: &[u8]) -> u64 {
30    const OFFSET_BASIS: u64 = 14_695_981_039_346_656_037;
31    const FNV_PRIME: u64 = 1_099_511_628_211;
32    let mut hash = OFFSET_BASIS;
33    for byte in data {
34        hash ^= *byte as u64;
35        hash = hash.wrapping_mul(FNV_PRIME);
36    }
37    hash
38}
39
40// ---------------------------------------------------------------------------
41// SchemaVersion
42// ---------------------------------------------------------------------------
43
44/// A versioned schema identifier that enables fast equality and compatibility
45/// checks via a FNV-1a fingerprint of field names and data types.
46#[derive(Clone, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
47pub struct SchemaVersion {
48    /// Logical name of the schema (e.g. `"embeddings"`, `"attention_layer"`).
49    pub name: String,
50    /// Monotonically increasing version number within the same named schema.
51    pub version: u32,
52    /// FNV-1a hash of every `"<field_name>:<data_type>"` string concatenated
53    /// together, in field declaration order, for fast inequality detection.
54    pub fingerprint: u64,
55}
56
57impl SchemaVersion {
58    /// Build a `SchemaVersion` from a name and an Arrow [`Schema`].
59    ///
60    /// The version starts at 1 for newly created versions; use
61    /// [`SchemaRegistry::register`] / [`SchemaRegistry::upgrade_schema`] to
62    /// obtain properly sequenced versions.
63    pub fn new(name: &str, schema: &Schema) -> Self {
64        Self {
65            name: name.to_owned(),
66            version: 1,
67            fingerprint: Self::fingerprint_of(schema),
68        }
69    }
70
71    /// Compute the FNV-1a fingerprint for a given [`Schema`].
72    ///
73    /// The fingerprint covers field names and their serialised data-type
74    /// strings in declaration order.
75    pub fn fingerprint_of(schema: &Schema) -> u64 {
76        let mut buf = String::new();
77        for field in schema.fields() {
78            buf.push_str(field.name());
79            buf.push(':');
80            buf.push_str(&format!("{:?}", field.data_type()));
81            buf.push(';');
82        }
83        fnv1a_64(buf.as_bytes())
84    }
85
86    /// Returns `true` when `self` is compatible with (can be read by) `other`.
87    ///
88    /// Compatibility requires:
89    /// - Same schema name.
90    /// - `self.version <= other.version` (older writer readable by newer reader).
91    pub fn is_compatible_with(&self, other: &SchemaVersion) -> bool {
92        self.name == other.name && self.version <= other.version
93    }
94}
95
96// ---------------------------------------------------------------------------
97// SchemaError
98// ---------------------------------------------------------------------------
99
100/// Errors that can occur during schema registration or evolution.
101#[derive(Debug, thiserror::Error)]
102pub enum SchemaError {
103    /// The requested schema name is not registered.
104    #[error("Schema '{0}' not found")]
105    NotFound(String),
106
107    /// The proposed schema change violates the active evolution strategy.
108    #[error("Incompatible schema change: {0}")]
109    Incompatible(String),
110
111    /// A schema with this version number is already registered.
112    #[error("Schema version {0} already registered")]
113    AlreadyRegistered(u32),
114}
115
116// ---------------------------------------------------------------------------
117// EvolutionStrategy
118// ---------------------------------------------------------------------------
119
120/// Governs which kinds of schema changes are permitted during an ongoing
121/// TensorSwap session.
122#[derive(Debug, Clone)]
123pub enum EvolutionStrategy {
124    /// Only nullable columns may be appended to the schema; existing fields
125    /// must remain unchanged.  Always backward-compatible.
126    AddColumnsOnly,
127
128    /// Columns may be renamed via an explicit old→new mapping.  All renames
129    /// must be listed; unlisted fields must be identical.
130    RenameColumns {
131        /// Map from old field name to new field name.
132        mapping: HashMap<String, String>,
133    },
134
135    /// The schema may change arbitrarily.  If the remote peer does not
136    /// advertise support for `FullReplacement`, reconnection is required.
137    FullReplacement,
138}
139
140// ---------------------------------------------------------------------------
141// SchemaRegistry
142// ---------------------------------------------------------------------------
143
144/// Registry that maps versioned [`SchemaVersion`] keys to Arrow [`Schema`]
145/// objects and enforces evolution constraints.
146pub struct SchemaRegistry {
147    /// Primary store: version key → schema.
148    schemas: HashMap<SchemaVersion, Arc<Schema>>,
149    /// Tracks the latest version number for each named schema.
150    latest: HashMap<String, u32>,
151}
152
153impl Default for SchemaRegistry {
154    fn default() -> Self {
155        Self::new()
156    }
157}
158
159impl SchemaRegistry {
160    /// Create an empty registry.
161    pub fn new() -> Self {
162        Self {
163            schemas: HashMap::new(),
164            latest: HashMap::new(),
165        }
166    }
167
168    /// Register a schema under `name`, automatically assigning the next
169    /// version number.  Returns the [`SchemaVersion`] that was assigned.
170    ///
171    /// If this is the first registration for `name` the version is set to 1.
172    pub fn register(&mut self, name: &str, schema: Arc<Schema>) -> SchemaVersion {
173        let version = self.latest.get(name).copied().unwrap_or(0) + 1;
174        let sv = SchemaVersion {
175            name: name.to_owned(),
176            version,
177            fingerprint: SchemaVersion::fingerprint_of(&schema),
178        };
179        self.schemas.insert(sv.clone(), schema);
180        self.latest.insert(name.to_owned(), version);
181        sv
182    }
183
184    /// Look up the [`Schema`] for a specific [`SchemaVersion`].
185    pub fn get(&self, version: &SchemaVersion) -> Option<Arc<Schema>> {
186        self.schemas.get(version).cloned()
187    }
188
189    /// Return the [`SchemaVersion`] with the highest version number for
190    /// `name`, or `None` if the name has never been registered.
191    pub fn latest_version(&self, name: &str) -> Option<SchemaVersion> {
192        let ver = *self.latest.get(name)?;
193        self.schemas
194            .keys()
195            .find(|sv| sv.name == name && sv.version == ver)
196            .cloned()
197    }
198
199    /// Attempt to register a new version of `name` using the
200    /// [`EvolutionStrategy::AddColumnsOnly`] rule: only nullable fields may be
201    /// added; no existing field may be removed or have its type changed.
202    ///
203    /// For richer strategies call [`Self::upgrade_schema_with_strategy`].
204    pub fn upgrade_schema(
205        &mut self,
206        name: &str,
207        new_schema: Arc<Schema>,
208    ) -> Result<SchemaVersion, SchemaError> {
209        self.upgrade_schema_with_strategy(name, new_schema, &EvolutionStrategy::AddColumnsOnly)
210    }
211
212    /// Upgrade a schema using an explicit [`EvolutionStrategy`].
213    ///
214    /// - [`EvolutionStrategy::AddColumnsOnly`]: new fields must be nullable;
215    ///   existing fields must be unchanged.
216    /// - [`EvolutionStrategy::RenameColumns`]: applies the rename mapping then
217    ///   validates that all other fields are unchanged.
218    /// - [`EvolutionStrategy::FullReplacement`]: always succeeds (the caller
219    ///   is responsible for ensuring the peer supports this).
220    pub fn upgrade_schema_with_strategy(
221        &mut self,
222        name: &str,
223        new_schema: Arc<Schema>,
224        strategy: &EvolutionStrategy,
225    ) -> Result<SchemaVersion, SchemaError> {
226        // Retrieve the current latest schema for comparison.
227        let current_sv = self
228            .latest_version(name)
229            .ok_or_else(|| SchemaError::NotFound(name.to_owned()))?;
230
231        let current_schema = self
232            .get(&current_sv)
233            .ok_or_else(|| SchemaError::NotFound(name.to_owned()))?;
234
235        match strategy {
236            EvolutionStrategy::AddColumnsOnly => {
237                validate_add_columns_only(&current_schema, &new_schema)?;
238            }
239            EvolutionStrategy::RenameColumns { mapping } => {
240                validate_rename_columns(&current_schema, &new_schema, mapping)?;
241            }
242            EvolutionStrategy::FullReplacement => {
243                // No structural validation; any schema is accepted.
244            }
245        }
246
247        // Assign the next version and store.
248        Ok(self.register(name, new_schema))
249    }
250
251    /// Determine whether data written with `writer_version` can be decoded by
252    /// a reader holding `reader_version`.
253    ///
254    /// This performs a structural check on the stored schemas in addition to
255    /// the version number check in [`SchemaVersion::is_compatible_with`].
256    pub fn can_read_with(
257        &self,
258        writer_version: &SchemaVersion,
259        reader_version: &SchemaVersion,
260    ) -> bool {
261        if !writer_version.is_compatible_with(reader_version) {
262            return false;
263        }
264
265        // Obtain both schemas; if either is missing we cannot confirm.
266        let (Some(writer_schema), Some(reader_schema)) =
267            (self.get(writer_version), self.get(reader_version))
268        else {
269            return false;
270        };
271
272        // Every field the writer produced must be present and type-compatible
273        // in the reader schema.
274        for writer_field in writer_schema.fields() {
275            match reader_schema.field_with_name(writer_field.name()) {
276                Ok(reader_field) => {
277                    if !types_compatible(writer_field.data_type(), reader_field.data_type()) {
278                        return false;
279                    }
280                }
281                Err(_) => {
282                    // Writer field missing from reader schema — incompatible.
283                    return false;
284                }
285            }
286        }
287
288        true
289    }
290}
291
292// ---------------------------------------------------------------------------
293// SchemaEvolutionFrame
294// ---------------------------------------------------------------------------
295
296/// Wire frame that signals a schema change mid-stream in a TensorSwap session.
297///
298/// When a sender wants to start emitting data using a different Arrow schema it
299/// first serialises a `SchemaEvolutionFrame` and transmits it to the receiver.
300/// The receiver must acknowledge the new schema before the sender continues
301/// with Arrow IPC batches encoded against `new_version`.
302#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
303pub struct SchemaEvolutionFrame {
304    /// Identifier of the TensorSwap session this evolution applies to.
305    pub session_id: String,
306    /// The schema version that was active before the change.
307    pub old_version: SchemaVersion,
308    /// The schema version that will be used after the change.
309    pub new_version: SchemaVersion,
310    /// Serialised Arrow IPC `Schema` message bytes for `new_version`.
311    ///
312    /// Encoded using `arrow::ipc::writer::schema_to_bytes` so that the
313    /// receiver can reconstruct the full [`Schema`] without registry access.
314    pub schema_ipc: Vec<u8>,
315}
316
317impl SchemaEvolutionFrame {
318    /// Construct a `SchemaEvolutionFrame`, serialising `new_schema` into Arrow
319    /// IPC format automatically.
320    pub fn new(
321        session_id: impl Into<String>,
322        old_version: SchemaVersion,
323        new_version: SchemaVersion,
324        new_schema: &Schema,
325    ) -> Self {
326        let schema_ipc = schema_to_ipc_bytes(new_schema);
327        Self {
328            session_id: session_id.into(),
329            old_version,
330            new_version,
331            schema_ipc,
332        }
333    }
334
335    /// Serialise this frame to JSON bytes suitable for transmission.
336    pub fn to_bytes(&self) -> Result<Vec<u8>, SchemaError> {
337        serde_json::to_vec(self)
338            .map_err(|e| SchemaError::Incompatible(format!("serialisation error: {e}")))
339    }
340
341    /// Deserialise a `SchemaEvolutionFrame` from JSON bytes.
342    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SchemaError> {
343        serde_json::from_slice(bytes)
344            .map_err(|e| SchemaError::Incompatible(format!("deserialisation error: {e}")))
345    }
346
347    /// Recover the Arrow [`Schema`] embedded in `schema_ipc`.
348    pub fn decode_schema(&self) -> Result<Arc<Schema>, SchemaError> {
349        ipc_bytes_to_schema(&self.schema_ipc)
350    }
351}
352
353// ---------------------------------------------------------------------------
354// Arrow IPC helpers
355// ---------------------------------------------------------------------------
356
357/// Serialise an Arrow [`Schema`] to IPC `Schema` message bytes.
358///
359/// The bytes include the 4-byte continuation marker and 4-byte length prefix
360/// that Arrow IPC streaming format prepends to every message.
361pub fn schema_to_ipc_bytes(schema: &Schema) -> Vec<u8> {
362    use arrow::ipc::writer::{DictionaryTracker, IpcDataGenerator, IpcWriteOptions};
363
364    let opts = IpcWriteOptions::default();
365    let gen = IpcDataGenerator {};
366    let mut tracker = DictionaryTracker::new(false);
367    let encoded = gen.schema_to_bytes_with_dictionary_tracker(schema, &mut tracker, &opts);
368    // `ipc_message` is the flatbuffer bytes for the Message; prepend the
369    // standard IPC continuation marker (4×0xFF) and the 32-bit length.
370    let msg = &encoded.ipc_message;
371    let len = msg.len() as u32;
372    let mut out = Vec::with_capacity(8 + msg.len());
373    out.extend_from_slice(&[0xFF, 0xFF, 0xFF, 0xFF]);
374    out.extend_from_slice(&len.to_le_bytes());
375    out.extend_from_slice(msg);
376    out
377}
378
379/// Deserialise an Arrow [`Schema`] from IPC `Schema` message bytes.
380///
381/// Accepts bytes produced by [`schema_to_ipc_bytes`] (with or without the
382/// 8-byte continuation+length prefix).
383pub fn ipc_bytes_to_schema(bytes: &[u8]) -> Result<Arc<Schema>, SchemaError> {
384    use arrow::ipc::convert::fb_to_schema;
385    use arrow::ipc::root_as_message;
386
387    // The IPC format prepends a 4-byte continuation marker (0xFF×4) followed
388    // by a 4-byte little-endian message length.  Strip them when present.
389    let payload = if bytes.len() >= 8 && bytes[0..4] == [0xFF, 0xFF, 0xFF, 0xFF] {
390        &bytes[8..]
391    } else {
392        bytes
393    };
394
395    let message = root_as_message(payload)
396        .map_err(|e| SchemaError::Incompatible(format!("invalid Arrow IPC message: {e}")))?;
397
398    let schema_ref = message.header_as_schema().ok_or_else(|| {
399        SchemaError::Incompatible("IPC message does not contain a Schema header".to_owned())
400    })?;
401
402    Ok(Arc::new(fb_to_schema(schema_ref)))
403}
404
405// ---------------------------------------------------------------------------
406// Validation helpers
407// ---------------------------------------------------------------------------
408
409/// Validate an [`EvolutionStrategy::AddColumnsOnly`] transition.
410///
411/// Rules:
412/// 1. All fields present in `old` must exist with the same name and data type
413///    in `new`.
414/// 2. Any field that appears only in `new` must be nullable.
415fn validate_add_columns_only(old: &Schema, new: &Schema) -> Result<(), SchemaError> {
416    // Rule 1: every old field must survive.
417    for old_field in old.fields() {
418        match new.field_with_name(old_field.name()) {
419            Ok(new_field) => {
420                if !types_compatible(old_field.data_type(), new_field.data_type()) {
421                    return Err(SchemaError::Incompatible(format!(
422                        "field '{}' changed type from {:?} to {:?}",
423                        old_field.name(),
424                        old_field.data_type(),
425                        new_field.data_type(),
426                    )));
427                }
428            }
429            Err(_) => {
430                return Err(SchemaError::Incompatible(format!(
431                    "existing field '{}' was removed",
432                    old_field.name()
433                )));
434            }
435        }
436    }
437
438    // Rule 2: new fields must be nullable.
439    for new_field in new.fields() {
440        if old.field_with_name(new_field.name()).is_err() && !new_field.is_nullable() {
441            return Err(SchemaError::Incompatible(format!(
442                "new field '{}' must be nullable when using AddColumnsOnly strategy",
443                new_field.name()
444            )));
445        }
446    }
447
448    Ok(())
449}
450
451/// Validate an [`EvolutionStrategy::RenameColumns`] transition.
452///
453/// After applying the rename mapping the resulting set of fields must be
454/// structurally identical to `new` (same names, same types).
455fn validate_rename_columns(
456    old: &Schema,
457    new: &Schema,
458    mapping: &HashMap<String, String>,
459) -> Result<(), SchemaError> {
460    // Build a renamed view of the old schema.
461    let renamed_fields: Vec<Arc<Field>> = old
462        .fields()
463        .iter()
464        .map(|f| {
465            let new_name = mapping
466                .get(f.name())
467                .cloned()
468                .unwrap_or_else(|| f.name().clone());
469            Arc::new(Field::new(new_name, f.data_type().clone(), f.is_nullable()))
470        })
471        .collect();
472
473    // Every field in `new` must appear in the renamed view with matching type.
474    for new_field in new.fields() {
475        let found = renamed_fields
476            .iter()
477            .find(|rf| rf.name() == new_field.name());
478        match found {
479            Some(rf) => {
480                if !types_compatible(rf.data_type(), new_field.data_type()) {
481                    return Err(SchemaError::Incompatible(format!(
482                        "field '{}' changed type after rename",
483                        new_field.name()
484                    )));
485                }
486            }
487            None => {
488                // A brand-new field is only allowed if it is nullable
489                // (AddColumnsOnly rule re-applied after renaming).
490                if !new_field.is_nullable() {
491                    return Err(SchemaError::Incompatible(format!(
492                        "new non-nullable field '{}' not covered by rename mapping",
493                        new_field.name()
494                    )));
495                }
496            }
497        }
498    }
499
500    // Every renamed field must still exist in `new`.
501    for rf in &renamed_fields {
502        if new.field_with_name(rf.name()).is_err() {
503            return Err(SchemaError::Incompatible(format!(
504                "renamed field '{}' is absent from the new schema",
505                rf.name()
506            )));
507        }
508    }
509
510    Ok(())
511}
512
513/// Return `true` when the two [`DataType`]s are considered equivalent for the
514/// purpose of read compatibility.  Currently requires exact equality; extend
515/// this function for widening casts if needed.
516fn types_compatible(writer: &DataType, reader: &DataType) -> bool {
517    writer == reader
518}
519
520// ---------------------------------------------------------------------------
521// Tests
522// ---------------------------------------------------------------------------
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use arrow::datatypes::{DataType, Field, Schema};
528    use std::sync::Arc;
529
530    fn make_schema(fields: Vec<(&str, DataType, bool)>) -> Arc<Schema> {
531        let arrow_fields: Vec<Field> = fields
532            .into_iter()
533            .map(|(name, dt, nullable)| Field::new(name, dt, nullable))
534            .collect();
535        Arc::new(Schema::new(arrow_fields))
536    }
537
538    // -----------------------------------------------------------------------
539    // SchemaVersion fingerprint tests
540    // -----------------------------------------------------------------------
541
542    #[test]
543    fn test_schema_version_fingerprint_deterministic() {
544        let schema = make_schema(vec![
545            ("id", DataType::Int64, false),
546            ("embedding", DataType::Float32, false),
547        ]);
548        let fp1 = SchemaVersion::fingerprint_of(&schema);
549        let fp2 = SchemaVersion::fingerprint_of(&schema);
550        assert_eq!(fp1, fp2, "fingerprint must be deterministic");
551    }
552
553    #[test]
554    fn test_schema_version_different_schemas() {
555        let schema_a = make_schema(vec![("id", DataType::Int64, false)]);
556        let schema_b = make_schema(vec![("id", DataType::Int32, false)]);
557        let fp_a = SchemaVersion::fingerprint_of(&schema_a);
558        let fp_b = SchemaVersion::fingerprint_of(&schema_b);
559        assert_ne!(
560            fp_a, fp_b,
561            "different schemas must produce different fingerprints"
562        );
563    }
564
565    // -----------------------------------------------------------------------
566    // SchemaRegistry: register and get
567    // -----------------------------------------------------------------------
568
569    #[test]
570    fn test_registry_register_and_get() {
571        let mut reg = SchemaRegistry::new();
572        let schema = make_schema(vec![("x", DataType::Float32, false)]);
573        let sv = reg.register("model", schema.clone());
574        assert_eq!(sv.name, "model");
575        assert_eq!(sv.version, 1);
576        let retrieved = reg.get(&sv).expect("schema should be retrievable");
577        assert_eq!(retrieved.fields().len(), 1);
578    }
579
580    // -----------------------------------------------------------------------
581    // SchemaRegistry: upgrade – add nullable column (compatible)
582    // -----------------------------------------------------------------------
583
584    #[test]
585    fn test_registry_upgrade_add_column() {
586        let mut reg = SchemaRegistry::new();
587        let schema_v1 = make_schema(vec![("x", DataType::Float32, false)]);
588        reg.register("model", schema_v1);
589
590        let schema_v2 = make_schema(vec![
591            ("x", DataType::Float32, false),
592            ("bias", DataType::Float32, true), // nullable — compatible
593        ]);
594        let sv2 = reg
595            .upgrade_schema("model", schema_v2)
596            .expect("adding nullable column must succeed");
597        assert_eq!(sv2.version, 2);
598    }
599
600    // -----------------------------------------------------------------------
601    // SchemaRegistry: upgrade – incompatible (non-nullable new column)
602    // -----------------------------------------------------------------------
603
604    #[test]
605    fn test_registry_upgrade_incompatible() {
606        let mut reg = SchemaRegistry::new();
607        let schema_v1 = make_schema(vec![("x", DataType::Float32, false)]);
608        reg.register("model", schema_v1);
609
610        // Removing an existing field is incompatible.
611        let schema_bad = make_schema(vec![
612            ("y", DataType::Float32, false), // "x" is gone — not allowed
613        ]);
614        let result = reg.upgrade_schema("model", schema_bad);
615        assert!(
616            matches!(result, Err(SchemaError::Incompatible(_))),
617            "removing a field must be incompatible"
618        );
619    }
620
621    // -----------------------------------------------------------------------
622    // SchemaRegistry: can_read_with
623    // -----------------------------------------------------------------------
624
625    #[test]
626    fn test_can_read_with() {
627        let mut reg = SchemaRegistry::new();
628        let schema_v1 = make_schema(vec![("x", DataType::Float32, false)]);
629        let sv1 = reg.register("model", schema_v1);
630
631        let schema_v2 = make_schema(vec![
632            ("x", DataType::Float32, false),
633            ("y", DataType::Float32, true),
634        ]);
635        let sv2 = reg
636            .upgrade_schema("model", schema_v2)
637            .expect("test: upgrade schema");
638
639        // A reader at v2 can read data written by v1 (older writer).
640        assert!(
641            reg.can_read_with(&sv1, &sv2),
642            "newer reader must be able to read older writer data"
643        );
644
645        // A reader at v1 cannot read data written by v2 (newer writer has extra field).
646        assert!(
647            !reg.can_read_with(&sv2, &sv1),
648            "older reader must NOT be able to read newer writer data with extra fields"
649        );
650    }
651
652    // -----------------------------------------------------------------------
653    // SchemaEvolutionFrame: serialise / deserialise round-trip
654    // -----------------------------------------------------------------------
655
656    #[test]
657    fn test_schema_evolution_frame_roundtrip() {
658        let mut reg = SchemaRegistry::new();
659        let schema_v1 = make_schema(vec![("a", DataType::Int32, false)]);
660        let sv1 = reg.register("tensors", schema_v1);
661
662        let schema_v2 = make_schema(vec![
663            ("a", DataType::Int32, false),
664            ("b", DataType::Float64, true),
665        ]);
666        let sv2 = reg
667            .upgrade_schema("tensors", schema_v2.clone())
668            .expect("test: upgrade schema");
669
670        let frame = SchemaEvolutionFrame::new("session-42", sv1.clone(), sv2.clone(), &schema_v2);
671
672        let bytes = frame.to_bytes().expect("serialisation must succeed");
673        let decoded =
674            SchemaEvolutionFrame::from_bytes(&bytes).expect("deserialisation must succeed");
675
676        assert_eq!(decoded.session_id, "session-42");
677        assert_eq!(decoded.old_version, sv1);
678        assert_eq!(decoded.new_version, sv2);
679
680        let recovered = decoded.decode_schema().expect("IPC decode must succeed");
681        assert_eq!(recovered.fields().len(), 2);
682        assert_eq!(recovered.field(0).name(), "a");
683        assert_eq!(recovered.field(1).name(), "b");
684    }
685
686    // -----------------------------------------------------------------------
687    // latest_version
688    // -----------------------------------------------------------------------
689
690    #[test]
691    fn test_latest_version() {
692        let mut reg = SchemaRegistry::new();
693        assert!(reg.latest_version("missing").is_none());
694
695        let s = make_schema(vec![("v", DataType::Utf8, true)]);
696        reg.register("ns", s.clone());
697        let sv = reg.latest_version("ns").expect("test: get latest version");
698        assert_eq!(sv.version, 1);
699
700        let s2 = make_schema(vec![
701            ("v", DataType::Utf8, true),
702            ("w", DataType::Int8, true),
703        ]);
704        reg.upgrade_schema("ns", s2).expect("test: upgrade schema");
705        let sv2 = reg.latest_version("ns").expect("test: get latest version");
706        assert_eq!(sv2.version, 2);
707    }
708
709    // -----------------------------------------------------------------------
710    // RenameColumns strategy
711    // -----------------------------------------------------------------------
712
713    #[test]
714    fn test_upgrade_rename_columns() {
715        let mut reg = SchemaRegistry::new();
716        let schema_v1 = make_schema(vec![("old_name", DataType::Float32, false)]);
717        reg.register("layer", schema_v1);
718
719        let schema_v2 = make_schema(vec![("new_name", DataType::Float32, false)]);
720        let mut map = HashMap::new();
721        map.insert("old_name".to_owned(), "new_name".to_owned());
722        let result = reg.upgrade_schema_with_strategy(
723            "layer",
724            schema_v2,
725            &EvolutionStrategy::RenameColumns { mapping: map },
726        );
727        assert!(result.is_ok(), "valid rename must succeed: {result:?}");
728    }
729
730    // -----------------------------------------------------------------------
731    // FullReplacement strategy
732    // -----------------------------------------------------------------------
733
734    #[test]
735    fn test_upgrade_full_replacement() {
736        let mut reg = SchemaRegistry::new();
737        let schema_v1 = make_schema(vec![("a", DataType::Int64, false)]);
738        reg.register("data", schema_v1);
739
740        // Completely different schema — only allowed under FullReplacement.
741        let schema_v2 = make_schema(vec![("z", DataType::Boolean, false)]);
742        let result = reg.upgrade_schema_with_strategy(
743            "data",
744            schema_v2,
745            &EvolutionStrategy::FullReplacement,
746        );
747        assert!(result.is_ok(), "FullReplacement must accept any new schema");
748    }
749}