#![deny(clippy::unwrap_used)]
use std::sync::Arc;
use arrow_array::{
Array, ArrayRef, FixedSizeListArray, Float32Array, Int64Array, LargeStringArray, RecordBatch,
};
use arrow_schema::{DataType, Field, Schema};
use infino::{
VectorSearchOptions,
storage::{LocalFsStorageProvider, StorageProvider},
superfile::{
builder::FtsConfig,
fts::reader::{Bm25Stats, BoolMode},
},
supertable::{Supertable, SupertableOptions},
test_helpers::{default_tokenizer, default_vector_config, lazy_foreground_disk_cache},
};
use tempfile::TempDir;
const DIM: usize = 16;
const VECTOR_ROT_SEED: u64 = 23;
const TOP_K: usize = 32;
const FIRST_BATCH: &[&str] = &["rust async fox", "lazy sleeping dog"];
const SECOND_BATCH: &[&str] = &["clever red fox", "old grey wolf"];
fn fixed_list_f32(dim: usize) -> DataType {
DataType::FixedSizeList(
Arc::new(Field::new("item", DataType::Float32, true)),
dim as i32,
)
}
fn cold_options() -> SupertableOptions {
let schema = Arc::new(Schema::new(vec![
Field::new("title", DataType::LargeUtf8, false),
Field::new("rating", DataType::Int64, false),
Field::new("emb", fixed_list_f32(DIM), false),
]));
SupertableOptions::new(
schema,
vec![FtsConfig {
column: "title".into(),
positions: false,
}],
vec![default_vector_config("emb", VECTOR_ROT_SEED)],
Some(default_tokenizer()),
)
.expect("valid options")
}
fn build_batch(titles: &[&str], base: usize, schema: Arc<Schema>) -> RecordBatch {
let n = titles.len();
let ratings: Vec<i64> = (0..n).map(|i| (base + i) as i64).collect();
let mut flat = Vec::<f32>::with_capacity(n * DIM);
for i in 0..n {
let active = (base + i) % DIM;
for d in 0..DIM {
flat.push(if d == active { 1.0 } else { 0.0 });
}
}
let fsl = FixedSizeListArray::try_new(
Arc::new(Field::new("item", DataType::Float32, true)),
DIM as i32,
Arc::new(Float32Array::from(flat)) as ArrayRef,
None,
)
.expect("FSL");
RecordBatch::try_new(
schema,
vec![
Arc::new(LargeStringArray::from(titles.to_vec())),
Arc::new(Int64Array::from(ratings)),
Arc::new(fsl),
],
)
.expect("batch")
}
fn cold_consumer() -> (Supertable, TempDir, TempDir) {
let dir = TempDir::new().expect("storage tempdir");
let storage: Arc<dyn StorageProvider> =
Arc::new(LocalFsStorageProvider::new(dir.path()).expect("provider"));
{
let producer =
Supertable::create(cold_options().with_storage(Arc::clone(&storage))).expect("create");
let schema = producer.options().schema.clone();
let mut w = producer.writer().expect("writer");
w.append(&build_batch(FIRST_BATCH, 0, schema.clone()))
.expect("append 1");
w.commit().expect("commit 1");
w.append(&build_batch(SECOND_BATCH, FIRST_BATCH.len(), schema))
.expect("append 2");
w.commit().expect("commit 2");
}
let cache_dir = TempDir::new().expect("cache tempdir");
let cache = lazy_foreground_disk_cache(Arc::clone(&storage), cache_dir.path());
let consumer = Supertable::open(
cold_options()
.with_storage(Arc::clone(&storage))
.with_disk_cache(cache),
)
.expect("cold open");
(consumer, dir, cache_dir)
}
fn projected_rows(batches: &[RecordBatch]) -> Vec<(String, i64)> {
let mut rows = Vec::new();
for batch in batches {
let title_idx = batch.schema().index_of("title").expect("title projected");
let rating_idx = batch.schema().index_of("rating").expect("rating projected");
let titles = batch
.column(title_idx)
.as_any()
.downcast_ref::<LargeStringArray>()
.expect("title column")
.clone();
let ratings = batch
.column(rating_idx)
.as_any()
.downcast_ref::<Int64Array>()
.expect("rating column")
.clone();
for row in 0..batch.num_rows() {
rows.push((titles.value(row).to_string(), ratings.value(row)));
}
}
rows
}
fn expected_rating(title: &str) -> i64 {
FIRST_BATCH
.iter()
.chain(SECOND_BATCH)
.position(|t| *t == title)
.expect("known title") as i64
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cold_bm25_projection_pairs_each_hit_with_its_own_row() {
let (st, _dir, _cache) = cold_consumer();
let hits = st
.reader()
.expect("reader")
.bm25_search(
"title",
"fox",
TOP_K,
BoolMode::Or,
Bm25Stats::PerSuperfile,
Some(&["_id", "title", "rating", "score"]),
)
.expect("cold bm25");
let rows = projected_rows(&hits);
assert_eq!(rows.len(), 2, "one fox per superfile");
for (title, rating) in &rows {
assert!(title.contains("fox"));
assert_eq!(
*rating,
expected_rating(title),
"projected rating must belong to the hit row"
);
}
let union = st
.reader()
.expect("reader")
.bm25_search(
"title",
"async wolf",
TOP_K,
BoolMode::Or,
Bm25Stats::PerSuperfile,
Some(&["_id", "title", "rating", "score"]),
)
.expect("cold bm25 or");
let mut union_titles: Vec<String> =
projected_rows(&union).into_iter().map(|(t, _)| t).collect();
union_titles.sort();
assert_eq!(union_titles, vec![SECOND_BATCH[1], FIRST_BATCH[0]]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cold_unranked_matches_project_rows_correctly() {
let (st, _dir, _cache) = cold_consumer();
let exact = st
.exact_match("title", SECOND_BATCH[0], Some(&["_id", "title", "rating"]))
.expect("cold exact_match");
let rows = projected_rows(&exact);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, SECOND_BATCH[0]);
assert_eq!(rows[0].1, expected_rating(SECOND_BATCH[0]));
let tokens = st
.token_match(
"title",
"grey wolf",
BoolMode::And,
Some(&["_id", "title", "rating"]),
)
.expect("cold token_match");
let rows = projected_rows(&tokens);
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].0, SECOND_BATCH[1]);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn cold_vector_projection_ranks_and_materializes() {
let (st, _dir, _cache) = cold_consumer();
let target = FIRST_BATCH.len();
let mut q = vec![0.0f32; DIM];
q[target % DIM] = 1.0;
let hits = st
.vector_search(
"emb",
&q,
TOP_K,
VectorSearchOptions::new(),
None,
Some(&["_id", "title", "rating", "score"]),
)
.expect("cold vector search");
let rows = projected_rows(&hits);
assert!(!rows.is_empty());
assert_eq!(rows[0].0, SECOND_BATCH[0], "top hit is the queried row");
assert_eq!(rows[0].1, target as i64);
for (title, rating) in &rows {
assert_eq!(
*rating,
expected_rating(title),
"every materialized row must be internally consistent"
);
}
}