use std::collections::BTreeSet;
use sha2::{Digest, Sha256};
use crate::{
EmbeddingContentDigest, EmbeddingNormalization, SearchArtifactError, VectorStoreLimits,
validate_vector, vector_schema,
};
#[derive(Clone, Debug, PartialEq)]
pub struct EmbeddingBatchRow {
pub node_uuid: [u8; 16],
pub vector: Vec<f32>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct ValidatedEmbeddingBatch {
rows: Vec<EmbeddingBatchRow>,
dimension: usize,
content_digest: EmbeddingContentDigest,
}
impl ValidatedEmbeddingBatch {
#[must_use]
pub fn rows(&self) -> &[EmbeddingBatchRow] {
&self.rows
}
#[must_use]
pub const fn dimension(&self) -> usize {
self.dimension
}
#[must_use]
pub const fn content_digest(&self) -> EmbeddingContentDigest {
self.content_digest
}
#[must_use]
pub fn into_rows(self) -> Vec<EmbeddingBatchRow> {
self.rows
}
}
pub fn validate_embedding_batch<C>(
mut rows: Vec<EmbeddingBatchRow>,
eligible_nodes: &BTreeSet<[u8; 16]>,
dimension: usize,
normalization: EmbeddingNormalization,
limits: VectorStoreLimits,
mut checkpoint: C,
) -> Result<ValidatedEmbeddingBatch, SearchArtifactError>
where
C: FnMut() -> Result<(), SearchArtifactError>,
{
checkpoint()?;
vector_schema(dimension, limits)?;
enforce_limit("embedding_rows", rows.len(), limits.stored_vectors)?;
enforce_limit(
"embedding_eligible_nodes",
eligible_nodes.len(),
limits.eligible_nodes,
)?;
let cells = rows
.len()
.checked_mul(dimension)
.ok_or_else(|| exhausted("embedding_vector_cells", limits.vector_cells))?;
enforce_limit("embedding_vector_cells", cells, limits.vector_cells)?;
rows.sort_unstable_by_key(|row| row.node_uuid);
for index in 0..rows.len() {
checkpoint()?;
if index != 0 && rows[index - 1].node_uuid == rows[index].node_uuid {
return Err(invalid("embedding batch", "contains duplicate node_uuid"));
}
if !eligible_nodes.contains(&rows[index].node_uuid) {
return Err(invalid(
"embedding batch",
"contains a node_uuid outside the eligible projection",
));
}
if rows[index].vector.len() != dimension {
return Err(invalid(
"embedding batch",
format!(
"node_uuid has dimension {}, expected {dimension}",
rows[index].vector.len()
),
));
}
let squared_norm = validate_vector(&rows[index].vector, limits)?;
if normalization == EmbeddingNormalization::L2 {
normalize_l2(&mut rows[index].vector, squared_norm);
}
}
if rows.len() != eligible_nodes.len() {
return Err(invalid(
"embedding batch",
format!(
"missing {} eligible node_uuid rows",
eligible_nodes.len().saturating_sub(rows.len())
),
));
}
let mut hasher = Sha256::new();
for row in &rows {
checkpoint()?;
hasher.update(row.node_uuid);
for value in &row.vector {
hasher.update(value.to_le_bytes());
}
}
let content_digest = EmbeddingContentDigest::from_hex(&format!("{:x}", hasher.finalize()))?;
Ok(ValidatedEmbeddingBatch {
rows,
dimension,
content_digest,
})
}
#[allow(clippy::cast_possible_truncation)]
fn normalize_l2(vector: &mut [f32], squared_norm: f64) {
let norm = squared_norm.sqrt();
for value in vector {
*value = (f64::from(*value) / norm) as f32;
}
}
fn enforce_limit(
resource: &'static str,
actual: usize,
limit: usize,
) -> Result<(), SearchArtifactError> {
if actual > limit {
return Err(exhausted(resource, limit));
}
Ok(())
}
fn invalid(field: &'static str, reason: impl Into<String>) -> SearchArtifactError {
SearchArtifactError::InvalidSelector {
field,
reason: reason.into(),
}
}
fn exhausted(resource: &'static str, limit: usize) -> SearchArtifactError {
SearchArtifactError::ResourceExhausted {
resource,
limit: u64::try_from(limit).unwrap_or(u64::MAX),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use super::*;
const A: [u8; 16] = [1; 16];
const B: [u8; 16] = [2; 16];
fn row(node_uuid: [u8; 16], vector: &[f32]) -> EmbeddingBatchRow {
EmbeddingBatchRow {
node_uuid,
vector: vector.to_vec(),
}
}
fn eligible(values: &[[u8; 16]]) -> BTreeSet<[u8; 16]> {
values.iter().copied().collect()
}
#[test]
fn input_order_cannot_change_rows_or_little_endian_digest() {
let limits = VectorStoreLimits::default();
let expected_nodes = eligible(&[A, B]);
let left = validate_embedding_batch(
vec![row(B, &[3.0, 4.0]), row(A, &[1.0, 2.0])],
&expected_nodes,
2,
EmbeddingNormalization::None,
limits,
|| Ok(()),
)
.unwrap();
let right = validate_embedding_batch(
vec![row(A, &[1.0, 2.0]), row(B, &[3.0, 4.0])],
&expected_nodes,
2,
EmbeddingNormalization::None,
limits,
|| Ok(()),
)
.unwrap();
let mut expected_bytes = Vec::new();
expected_bytes.extend_from_slice(&A);
expected_bytes.extend_from_slice(&1.0_f32.to_le_bytes());
expected_bytes.extend_from_slice(&2.0_f32.to_le_bytes());
expected_bytes.extend_from_slice(&B);
expected_bytes.extend_from_slice(&3.0_f32.to_le_bytes());
expected_bytes.extend_from_slice(&4.0_f32.to_le_bytes());
assert_eq!(left, right);
assert_eq!(left.rows()[0].node_uuid, A);
assert_eq!(
left.content_digest(),
EmbeddingContentDigest::digest(&expected_bytes)
);
}
#[test]
fn normalization_contract_is_explicit() {
let complete = eligible(&[A]);
let preserved = validate_embedding_batch(
vec![row(A, &[3.0, 4.0])],
&complete,
2,
EmbeddingNormalization::None,
VectorStoreLimits::default(),
|| Ok(()),
)
.unwrap();
let normalized = validate_embedding_batch(
vec![row(A, &[3.0, 4.0])],
&complete,
2,
EmbeddingNormalization::L2,
VectorStoreLimits::default(),
|| Ok(()),
)
.unwrap();
assert_eq!(preserved.rows()[0].vector, vec![3.0, 4.0]);
assert_eq!(normalized.rows()[0].vector, vec![0.6, 0.8]);
assert_ne!(preserved.content_digest(), normalized.content_digest());
}
#[test]
fn coverage_and_vector_validation_fail_closed() {
let limits = VectorStoreLimits::default();
let complete = eligible(&[A, B]);
let invalid_batches = [
vec![row(A, &[1.0, 0.0]), row(A, &[0.0, 1.0])],
vec![row(A, &[1.0, 0.0]), row([3; 16], &[0.0, 1.0])],
vec![row(A, &[1.0, 0.0])],
vec![row(A, &[1.0]), row(B, &[0.0, 1.0])],
vec![row(A, &[f32::NAN, 1.0]), row(B, &[0.0, 1.0])],
vec![row(A, &[0.0, 0.0]), row(B, &[0.0, 1.0])],
];
for rows in invalid_batches {
assert!(
validate_embedding_batch(
rows,
&complete,
2,
EmbeddingNormalization::None,
limits,
|| Ok(())
)
.is_err()
);
}
}
#[test]
fn limits_and_cancellation_return_no_validated_batch() {
assert!(
validate_embedding_batch(
Vec::new(),
&BTreeSet::new(),
0,
EmbeddingNormalization::None,
VectorStoreLimits::default(),
|| Ok(())
)
.is_err()
);
let mut limits = VectorStoreLimits::default();
limits.stored_vectors = 1;
assert!(matches!(
validate_embedding_batch(
vec![row(A, &[1.0]), row(B, &[1.0])],
&eligible(&[A, B]),
1,
EmbeddingNormalization::None,
limits,
|| Ok(())
),
Err(SearchArtifactError::ResourceExhausted {
resource: "embedding_rows",
..
})
));
let mut limits = VectorStoreLimits::default();
limits.eligible_nodes = 1;
assert!(matches!(
validate_embedding_batch(
vec![row(A, &[1.0]), row(B, &[1.0])],
&eligible(&[A, B]),
1,
EmbeddingNormalization::None,
limits,
|| Ok(())
),
Err(SearchArtifactError::ResourceExhausted {
resource: "embedding_eligible_nodes",
..
})
));
let mut limits = VectorStoreLimits::default();
limits.vector_cells = 1;
assert!(matches!(
validate_embedding_batch(
vec![row(A, &[1.0]), row(B, &[1.0])],
&eligible(&[A, B]),
1,
EmbeddingNormalization::None,
limits,
|| Ok(())
),
Err(SearchArtifactError::ResourceExhausted {
resource: "embedding_vector_cells",
..
})
));
let checkpoints = AtomicUsize::new(0);
assert!(matches!(
validate_embedding_batch(
vec![row(A, &[1.0]), row(B, &[1.0])],
&eligible(&[A, B]),
1,
EmbeddingNormalization::None,
VectorStoreLimits::default(),
|| {
(checkpoints.fetch_add(1, Ordering::Relaxed) < 2)
.then_some(())
.ok_or(SearchArtifactError::Cancelled)
}
),
Err(SearchArtifactError::Cancelled)
));
}
#[test]
fn empty_complete_projection_retains_fixed_dimension() {
let batch = validate_embedding_batch(
Vec::new(),
&BTreeSet::new(),
3,
EmbeddingNormalization::L2,
VectorStoreLimits::default(),
|| Ok(()),
)
.unwrap();
assert!(batch.rows().is_empty());
assert_eq!(batch.dimension(), 3);
assert_eq!(batch.content_digest(), EmbeddingContentDigest::digest(&[]));
}
}