use std::fs;
use std::path::Path;
use sha2::{Digest, Sha256};
use crate::buffer::{NumericFeatureBufferStore, NumericFeatureMatrixF64Columnar};
use crate::error::{DataError, Result};
use crate::ids::{ObservationId, RepresentationId};
const MAGIC: &[u8; 4] = b"N4DF";
pub const NUMERIC_FEATURE_BUFFER_FILE_FORMAT_VERSION: u32 = 1;
const TRAILER_LEN: usize = 32;
pub fn serialize_columnar_store(store: &NumericFeatureBufferStore) -> Vec<u8> {
let mut payload = Vec::new();
payload.extend_from_slice(MAGIC);
payload.extend_from_slice(&NUMERIC_FEATURE_BUFFER_FILE_FORMAT_VERSION.to_le_bytes());
let buffer_count = u32::try_from(store.len()).expect("store buffer count must fit in u32");
payload.extend_from_slice(&buffer_count.to_le_bytes());
for (feature_set_id, buffer) in store.iter() {
debug_assert_eq!(feature_set_id, &buffer.feature_set_id);
let matrix = buffer.to_f64_column_matrix();
write_buffer(&mut payload, &matrix);
}
let digest = Sha256::digest(&payload);
payload.extend_from_slice(&digest);
payload
}
pub fn deserialize_columnar_store(bytes: &[u8]) -> Result<NumericFeatureBufferStore> {
if bytes.len() < 12 + TRAILER_LEN {
return Err(DataError::Validation(format!(
"feature buffer file is truncated: got {} bytes, need at least {}",
bytes.len(),
12 + TRAILER_LEN
)));
}
if &bytes[0..4] != MAGIC {
return Err(DataError::Validation(
"feature buffer file does not start with N4DF magic".to_string(),
));
}
let payload_end = bytes.len() - TRAILER_LEN;
let computed = Sha256::digest(&bytes[..payload_end]);
if computed.as_slice() != &bytes[payload_end..] {
return Err(DataError::Validation(
"feature buffer file SHA-256 trailer does not match payload".to_string(),
));
}
let version = read_u32(bytes, 4)?;
if version != NUMERIC_FEATURE_BUFFER_FILE_FORMAT_VERSION {
return Err(DataError::Validation(format!(
"feature buffer file uses unsupported format version {version}, expected {}",
NUMERIC_FEATURE_BUFFER_FILE_FORMAT_VERSION
)));
}
let buffer_count = read_u32(bytes, 8)? as usize;
let mut cursor = 12usize;
let mut matrices = Vec::with_capacity(buffer_count);
for _ in 0..buffer_count {
let (matrix, next_cursor) = read_buffer(bytes, cursor, payload_end)?;
matrices.push(matrix);
cursor = next_cursor;
}
if cursor != payload_end {
return Err(DataError::Validation(format!(
"feature buffer file has {} trailing bytes after {} declared buffer(s)",
payload_end - cursor,
buffer_count
)));
}
NumericFeatureBufferStore::from_f64_column_matrices(matrices)
}
pub fn write_store_to_path(store: &NumericFeatureBufferStore, path: &Path) -> Result<()> {
let bytes = serialize_columnar_store(store);
fs::write(path, bytes).map_err(|error| {
DataError::Validation(format!(
"failed to write feature buffer store to `{}`: {error}",
path.display()
))
})
}
pub fn read_store_from_path(path: &Path) -> Result<NumericFeatureBufferStore> {
let bytes = fs::read(path).map_err(|error| {
DataError::Validation(format!(
"failed to read feature buffer store from `{}`: {error}",
path.display()
))
})?;
deserialize_columnar_store(&bytes)
}
fn write_buffer(payload: &mut Vec<u8>, matrix: &NumericFeatureMatrixF64Columnar) {
write_string(payload, &matrix.feature_set_id);
write_string(payload, matrix.representation_id.as_str());
let row_count = u32::try_from(matrix.observation_ids.len()).expect("row count must fit in u32");
let feature_count =
u32::try_from(matrix.feature_names.len()).expect("feature count must fit in u32");
payload.extend_from_slice(&row_count.to_le_bytes());
payload.extend_from_slice(&feature_count.to_le_bytes());
let has_validity = u8::from(matrix.validity_masks.is_some());
payload.push(has_validity);
for name in &matrix.feature_names {
write_string(payload, name);
}
for observation_id in &matrix.observation_ids {
write_string(payload, observation_id.as_str());
}
for column in &matrix.columns {
debug_assert_eq!(column.len() as u32, row_count);
for value in column {
payload.extend_from_slice(&value.to_le_bytes());
}
}
if let Some(masks) = &matrix.validity_masks {
debug_assert_eq!(masks.len() as u32, feature_count);
for mask in masks {
debug_assert_eq!(mask.len() as u32, row_count);
for present in mask {
payload.push(u8::from(*present));
}
}
}
}
fn read_buffer(
bytes: &[u8],
cursor: usize,
payload_end: usize,
) -> Result<(NumericFeatureMatrixF64Columnar, usize)> {
let mut cursor = cursor;
let feature_set_id = read_string(bytes, &mut cursor, payload_end)?;
let representation_raw = read_string(bytes, &mut cursor, payload_end)?;
let representation_id = RepresentationId::new(&representation_raw)?;
let row_count = read_u32_advance(bytes, &mut cursor, payload_end)? as usize;
let feature_count = read_u32_advance(bytes, &mut cursor, payload_end)? as usize;
let has_validity = read_u8_advance(bytes, &mut cursor, payload_end)?;
if has_validity > 1 {
return Err(DataError::Validation(format!(
"feature buffer file `{feature_set_id}` has invalid has_validity byte {has_validity}"
)));
}
let mut feature_names = Vec::with_capacity(feature_count);
for _ in 0..feature_count {
feature_names.push(read_string(bytes, &mut cursor, payload_end)?);
}
let mut observation_ids = Vec::with_capacity(row_count);
for _ in 0..row_count {
let raw = read_string(bytes, &mut cursor, payload_end)?;
observation_ids.push(ObservationId::new(&raw)?);
}
let mut columns = Vec::with_capacity(feature_count);
for _ in 0..feature_count {
let needed = row_count.checked_mul(8).ok_or_else(|| {
DataError::Validation(format!(
"feature buffer file `{feature_set_id}` column size overflows usize"
))
})?;
ensure_remaining(payload_end, cursor, needed, &feature_set_id)?;
let mut column = Vec::with_capacity(row_count);
for row in 0..row_count {
let offset = cursor + row * 8;
let value = f64::from_le_bytes(
bytes[offset..offset + 8]
.try_into()
.expect("8 bytes for f64 LE"),
);
column.push(value);
}
cursor += needed;
columns.push(column);
}
let validity_masks = if has_validity == 1 {
let mut masks = Vec::with_capacity(feature_count);
for _ in 0..feature_count {
ensure_remaining(payload_end, cursor, row_count, &feature_set_id)?;
let mut mask = Vec::with_capacity(row_count);
for offset in 0..row_count {
let byte = bytes[cursor + offset];
if byte > 1 {
return Err(DataError::Validation(format!(
"feature buffer file `{feature_set_id}` validity byte {byte} is not 0 or 1"
)));
}
mask.push(byte == 1);
}
cursor += row_count;
masks.push(mask);
}
Some(masks)
} else {
None
};
Ok((
NumericFeatureMatrixF64Columnar {
feature_set_id,
representation_id,
feature_names,
observation_ids,
columns,
validity_masks,
},
cursor,
))
}
fn write_string(payload: &mut Vec<u8>, value: &str) {
let bytes = value.as_bytes();
let len = u32::try_from(bytes.len()).expect("string length must fit in u32");
payload.extend_from_slice(&len.to_le_bytes());
payload.extend_from_slice(bytes);
}
fn read_string(bytes: &[u8], cursor: &mut usize, payload_end: usize) -> Result<String> {
let len = read_u32_advance(bytes, cursor, payload_end)? as usize;
ensure_remaining(payload_end, *cursor, len, "feature buffer file string")?;
let raw = &bytes[*cursor..*cursor + len];
let value = std::str::from_utf8(raw)
.map_err(|error| DataError::Validation(format!("invalid utf-8 string: {error}")))?
.to_string();
*cursor += len;
Ok(value)
}
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32> {
if offset + 4 > bytes.len() {
return Err(DataError::Validation(format!(
"feature buffer file truncated reading u32 at offset {offset}"
)));
}
Ok(u32::from_le_bytes(
bytes[offset..offset + 4]
.try_into()
.expect("4 bytes for u32 LE"),
))
}
fn read_u32_advance(bytes: &[u8], cursor: &mut usize, payload_end: usize) -> Result<u32> {
ensure_remaining(payload_end, *cursor, 4, "feature buffer file u32")?;
let value = u32::from_le_bytes(
bytes[*cursor..*cursor + 4]
.try_into()
.expect("4 bytes for u32 LE"),
);
*cursor += 4;
Ok(value)
}
fn read_u8_advance(bytes: &[u8], cursor: &mut usize, payload_end: usize) -> Result<u8> {
ensure_remaining(payload_end, *cursor, 1, "feature buffer file u8")?;
let value = bytes[*cursor];
*cursor += 1;
Ok(value)
}
fn ensure_remaining(payload_end: usize, cursor: usize, needed: usize, label: &str) -> Result<()> {
if cursor + needed > payload_end {
return Err(DataError::Validation(format!(
"{label} truncated at cursor {cursor}: needed {needed} bytes, payload ends at {payload_end}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::buffer::NumericFeatureMatrixF64Columnar;
fn matrix(feature_set_id: &str) -> NumericFeatureMatrixF64Columnar {
NumericFeatureMatrixF64Columnar {
feature_set_id: feature_set_id.to_string(),
representation_id: RepresentationId::new("tabular_numeric").unwrap(),
feature_names: vec!["f0".to_string(), "f1".to_string()],
observation_ids: vec![
ObservationId::new("obs.A").unwrap(),
ObservationId::new("obs.B").unwrap(),
ObservationId::new("obs.C").unwrap(),
],
columns: vec![vec![1.0, 2.0, 3.0], vec![10.0, 20.0, 30.0]],
validity_masks: Some(vec![vec![true, true, false], vec![true, true, true]]),
}
}
#[test]
fn round_trip_preserves_buffer_fingerprints_and_bytes_are_deterministic() {
let store_one =
NumericFeatureBufferStore::from_f64_column_matrices(vec![matrix("x"), matrix("y")])
.unwrap();
let store_two =
NumericFeatureBufferStore::from_f64_column_matrices(vec![matrix("y"), matrix("x")])
.unwrap();
let bytes_one = serialize_columnar_store(&store_one);
let bytes_two = serialize_columnar_store(&store_two);
assert_eq!(bytes_one, bytes_two, "byte order must be deterministic");
let restored = deserialize_columnar_store(&bytes_one).unwrap();
let original_manifests = store_one.manifests().unwrap();
let restored_manifests = restored.manifests().unwrap();
assert_eq!(restored_manifests.len(), original_manifests.len());
for (lhs, rhs) in original_manifests.iter().zip(restored_manifests.iter()) {
assert_eq!(lhs.feature_set_id, rhs.feature_set_id);
assert_eq!(lhs.row_count, rhs.row_count);
assert_eq!(lhs.feature_count, rhs.feature_count);
assert_eq!(lhs.buffer_fingerprint, rhs.buffer_fingerprint);
}
}
#[test]
fn buffers_without_validity_round_trip_correctly() {
let mut without_validity = matrix("z");
without_validity.validity_masks = None;
let store =
NumericFeatureBufferStore::from_f64_column_matrices(vec![without_validity.clone()])
.unwrap();
let bytes = serialize_columnar_store(&store);
let restored = deserialize_columnar_store(&bytes).unwrap();
assert_eq!(
restored.manifests().unwrap()[0].buffer_fingerprint,
store.manifests().unwrap()[0].buffer_fingerprint
);
}
#[test]
fn rejects_corrupted_payload() {
let store = NumericFeatureBufferStore::from_f64_column_matrices(vec![matrix("x")]).unwrap();
let mut bytes = serialize_columnar_store(&store);
bytes[15] ^= 0xFF;
let error = deserialize_columnar_store(&bytes).unwrap_err();
assert!(format!("{error}").contains("SHA-256 trailer does not match payload"));
}
#[test]
fn rejects_unknown_magic_and_version() {
let store = NumericFeatureBufferStore::from_f64_column_matrices(vec![matrix("x")]).unwrap();
let mut bytes = serialize_columnar_store(&store);
bytes[0..4].copy_from_slice(b"WRNG");
let error = deserialize_columnar_store(&bytes).unwrap_err();
assert!(format!("{error}").contains("N4DF magic"));
let mut bytes = serialize_columnar_store(&store);
bytes[4..8].copy_from_slice(&u32::MAX.to_le_bytes());
let payload_end = bytes.len() - TRAILER_LEN;
let digest = Sha256::digest(&bytes[..payload_end]);
bytes[payload_end..].copy_from_slice(&digest);
let error = deserialize_columnar_store(&bytes).unwrap_err();
assert!(format!("{error}").contains("unsupported format version"));
}
#[test]
fn rejects_truncated_file() {
let store = NumericFeatureBufferStore::from_f64_column_matrices(vec![matrix("x")]).unwrap();
let bytes = serialize_columnar_store(&store);
let truncated = &bytes[..bytes.len() - 1];
let error = deserialize_columnar_store(truncated).unwrap_err();
let message = format!("{error}");
assert!(
message.contains("truncated") || message.contains("trailer"),
"unexpected truncation error: {message}"
);
}
#[test]
fn empty_store_round_trips_at_minimum_size_boundary() {
let store = NumericFeatureBufferStore::default();
let bytes = serialize_columnar_store(&store);
assert_eq!(bytes.len(), 12 + TRAILER_LEN);
let restored = deserialize_columnar_store(&bytes).unwrap();
assert!(restored.is_empty());
}
#[test]
fn read_write_path_round_trip() {
let store =
NumericFeatureBufferStore::from_f64_column_matrices(vec![matrix("alpha")]).unwrap();
let path = std::env::temp_dir().join(format!(
"dag_ml_data_buffer_file_store_roundtrip_{}.n4d",
std::process::id()
));
write_store_to_path(&store, &path).unwrap();
let restored = read_store_from_path(&path).unwrap();
assert_eq!(restored.len(), 1);
assert_eq!(
restored.manifests().unwrap()[0].buffer_fingerprint,
store.manifests().unwrap()[0].buffer_fingerprint
);
let _ = std::fs::remove_file(&path);
}
}