diff --git a/diskann-benchmark-core/src/lib.rs b/diskann-benchmark-core/src/lib.rs index 920a7fe20..828359f66 100644 --- a/diskann-benchmark-core/src/lib.rs +++ b/diskann-benchmark-core/src/lib.rs @@ -40,7 +40,7 @@ //! in which method can fail, the [`anyhow::Error`] type balances generality and fidelity. mod internal; -pub(crate) mod utils; +pub mod utils; // Public Utility Modules pub mod recall; diff --git a/diskann-benchmark-core/src/utils.rs b/diskann-benchmark-core/src/utils.rs index 231754099..acff8c1c6 100644 --- a/diskann-benchmark-core/src/utils.rs +++ b/diskann-benchmark-core/src/utils.rs @@ -5,7 +5,8 @@ use diskann_benchmark_runner::utils::percentiles::AsF64Lossy; -pub(crate) fn average_all(x: I) -> f64 +/// Computes the arithmetic mean of an iterator of values, returning `0.0` when empty. +pub fn average_all(x: I) -> f64 where I: IntoIterator, { diff --git a/diskann-benchmark/example/flat-index.json b/diskann-benchmark/example/flat-index.json new file mode 100644 index 000000000..18d9170cd --- /dev/null +++ b/diskann-benchmark/example/flat-index.json @@ -0,0 +1,22 @@ +{ + "search_directories": [ + "test_data/disk_index_search" + ], + "jobs": [ + { + "type": "flat-search", + "content": { + "data": "disk_index_siftsmall_learn_256pts_data.fbin", + "data_type": "float32", + "distance": "squared_l2", + "search": { + "queries": "disk_index_sample_query_10pts.fbin", + "groundtruth": "disk_index_10pts_idx_uint32_truth_search_res.bin", + "k": 10, + "num_threads": [1], + "reps": 1 + } + } + } + ] +} diff --git a/diskann-benchmark/perf_test_inputs/wikipedia-100K-flat-index.json b/diskann-benchmark/perf_test_inputs/wikipedia-100K-flat-index.json new file mode 100644 index 000000000..cabb62b89 --- /dev/null +++ b/diskann-benchmark/perf_test_inputs/wikipedia-100K-flat-index.json @@ -0,0 +1,22 @@ +{ + "search_directories": [ + "target/tmp" + ], + "jobs": [ + { + "type": "flat-search", + "content": { + "data": "wikipedia_cohere/wikipedia_base.bin.crop_nb_100000", + "data_type": "float32", + "distance": "inner_product", + "search": { + "queries": "wikipedia_cohere/wikipedia_query.bin", + "groundtruth": "wikipedia_cohere/wikipedia-100K", + "k": 100, + "num_threads": [4, 8], + "reps": 1 + } + } + } + ] +} diff --git a/diskann-benchmark/src/flat/mod.rs b/diskann-benchmark/src/flat/mod.rs new file mode 100644 index 000000000..d7fe34f15 --- /dev/null +++ b/diskann-benchmark/src/flat/mod.rs @@ -0,0 +1,12 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +use diskann_benchmark_runner::Registry; + +mod search; + +pub(crate) fn register_benchmarks(registry: &mut Registry) -> anyhow::Result<()> { + search::register_benchmarks(registry) +} diff --git a/diskann-benchmark/src/flat/search.rs b/diskann-benchmark/src/flat/search.rs new file mode 100644 index 000000000..0f1c4f8cc --- /dev/null +++ b/diskann-benchmark/src/flat/search.rs @@ -0,0 +1,598 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +//! Backend for flat-index (brute-force kNN) benchmarks. +//! +//! This exercises [`diskann::flat::FlatIndex::knn_search`] over an in-memory +//! provider, measuring recall and latency. + +use std::{io::Write, num::NonZeroUsize, sync::Arc}; + +use diskann::{ + flat::{DistancesUnordered, FlatIndex, SearchStrategy}, + graph::{glue::CopyIds, SearchOutputBuffer}, + provider::{DataProvider, DefaultContext, HasId, NoopGuard}, + utils::VectorRepr, + ANNResult, +}; +use diskann_benchmark_core::{self as benchmark_core, recall::GroundTruthMode, search}; +use diskann_benchmark_runner::{ + benchmark::{FailureScore, MatchScore}, + output::Output, + utils::{datatype::AsDataType, percentiles, MicroSeconds}, + Benchmark, Checkpoint, Registry, +}; +use diskann_utils::{future::SendFuture, views::Matrix}; +use diskann_vector::{distance::Metric, PreprocessedDistanceFunction}; +use half::f16; +use serde::Serialize; + +use crate::{ + inputs::flat::FlatSearch, + utils::{self, datafiles, recall::RecallMetrics}, +}; + +//////////////////////////// +// Benchmark Registration // +//////////////////////////// + +const NAME: &str = "flat-index"; + +pub(super) fn register_benchmarks(registry: &mut Registry) -> anyhow::Result<()> { + registry.register(NAME, Flat::::new())?; + registry.register(NAME, Flat::::new())?; + registry.register(NAME, Flat::::new())?; + registry.register(NAME, Flat::::new())?; + Ok(()) +} + +///////////////// +// FlatSearch // +///////////////// + +/// A minimal in-memory provider for flat search benchmarks. +/// +/// Wraps a loaded [`Matrix`] and implements [`DataProvider`] with identity +/// ID mapping. +struct InMemProvider { + data: Arc>, +} + +impl DataProvider for InMemProvider { + type Context = DefaultContext; + type InternalId = u32; + type ExternalId = u32; + type Error = diskann::ANNError; + type Guard = NoopGuard; + + fn to_internal_id(&self, _ctx: &DefaultContext, gid: &u32) -> Result { + Ok(*gid) + } + + fn to_external_id(&self, _ctx: &DefaultContext, id: u32) -> Result { + Ok(id) + } +} + +struct Flat { + _phantom: std::marker::PhantomData, +} + +impl Flat { + fn new() -> Self { + Self { + _phantom: std::marker::PhantomData, + } + } +} + +impl Benchmark for Flat +where + T: VectorRepr + AsDataType, +{ + type Input = FlatSearch; + type Output = FlatResult; + + fn try_match(&self, input: &FlatSearch) -> Result { + utils::match_data_type::(input.data_type) + } + + fn description( + &self, + f: &mut std::fmt::Formatter<'_>, + input: Option<&FlatSearch>, + ) -> std::fmt::Result { + match input { + Some(i) => { + let desc = T::describe(i.data_type); + if !desc.is_match() { + writeln!(f, "Data Type: {}", desc)?; + } + Ok(()) + } + None => writeln!(f, "Data Type: {}", T::DATA_TYPE), + } + } + + fn run( + &self, + input: &FlatSearch, + _checkpoint: Checkpoint<'_>, + mut output: &mut dyn Output, + ) -> anyhow::Result { + writeln!(output, "{}", input)?; + + let metric: Metric = input.distance.into(); + + // Load dataset + writeln!(output, "Loading dataset...")?; + let data: Matrix = datafiles::load_dataset(datafiles::BinFile(&input.data))?; + let nrows = data.nrows(); + let ncols = data.ncols(); + anyhow::ensure!( + nrows <= u32::MAX as usize, + "flat-index benchmark requires <= {} vectors (got {}) to fit in u32 ids", + u32::MAX, + nrows, + ); + writeln!(output, " Loaded {} vectors of dimension {}", nrows, ncols)?; + + // Build the provider and wrap in FlatIndex + let data = Arc::new(data); + let provider = InMemProvider { data: data.clone() }; + let index = FlatIndex::new(provider); + + // Load queries and groundtruth + let queries: Matrix = + datafiles::load_dataset(datafiles::BinFile(&input.search.queries))?; + let groundtruth = datafiles::load_groundtruth( + datafiles::BinFile(&input.search.groundtruth), + Some(input.search.k.get()), + )?; + anyhow::ensure!( + ncols == queries.ncols(), + "dataset dimension ({}) does not match query dimension ({})", + ncols, + queries.ncols(), + ); + + writeln!( + output, + " Queries: {}, Groundtruth: {}x{}", + queries.nrows(), + groundtruth.nrows(), + groundtruth.ncols(), + )?; + + // Run searches for each thread count + let k = input.search.k; + let reps = input.search.reps; + anyhow::ensure!( + k.get() <= nrows, + "k ({}) must be <= number of dataset vectors ({})", + k, + nrows, + ); + + let mut results = Vec::new(); + + let searcher = Arc::new(Searcher { + index, + queries, + strategy: Strategy::new(metric), + }); + + for &threads in &input.search.num_threads { + let setup = search::Setup { + threads, + tasks: threads, + reps, + }; + + let run = search::Run::new(SearchParameters { k }, setup); + let aggregated = search::search_all( + searcher.clone(), + std::iter::once(run), + Aggregator::new(&groundtruth, k.get()), + )?; + + for item in aggregated { + results.push(item); + } + } + + let result = FlatResult { results }; + writeln!(output, "\n\n{}", result)?; + Ok(result) + } +} + +/////////////////////// +// Flat SearchStrategy // +/////////////////////// + +/// A [`SearchStrategy`] implementation for [`InMemProvider`] that drives +/// a full sequential scan over all vectors. +struct Strategy { + metric: Metric, + _phantom: std::marker::PhantomData, +} + +impl Strategy { + fn new(metric: Metric) -> Self { + Self { + metric, + _phantom: std::marker::PhantomData, + } + } +} + +/// The visitor that iterates over all vectors in the provider. +struct Visitor<'a, T> { + data: &'a Matrix, +} + +impl HasId for Visitor<'_, T> { + type Id = u32; +} + +impl DistancesUnordered for Visitor<'_, T> { + type ElementRef<'a> = &'a [T]; + type Error = diskann::error::Infallible; + + fn distances_unordered( + &mut self, + computer: &T::QueryDistance, + mut f: F, + ) -> impl SendFuture> + where + F: Send + FnMut(Self::Id, f32), + { + async move { + for (i, vector) in self.data.row_iter().enumerate() { + let dist = computer.evaluate_similarity(vector); + f(i as u32, dist); + } + Ok(()) + } + } +} + +impl SearchStrategy, &[T]> for Strategy { + type ElementRef<'a> = &'a [T]; + type QueryComputer = T::QueryDistance; + type QueryComputerError = diskann::error::Infallible; + type Visitor<'a> + = Visitor<'a, T> + where + Self: 'a, + InMemProvider: 'a; + type Error = diskann::error::Infallible; + + fn create_visitor<'a>( + &'a self, + provider: &'a InMemProvider, + _context: &'a DefaultContext, + ) -> Result, Self::Error> { + Ok(Visitor { + data: &provider.data, + }) + } + + fn build_query_computer( + &self, + query: &[T], + ) -> Result { + Ok(T::query_distance(query, self.metric)) + } +} + +////////////////////////////////////////// +// benchmark_core::search::Search impl // +////////////////////////////////////////// + +/// Wraps a [`FlatIndex`] and queries to implement the [`Search`] trait from benchmark_core. +struct Searcher { + index: FlatIndex>, + queries: Matrix, + strategy: Strategy, +} + +/// Search parameters for flat-index benchmarks. +#[derive(Debug, Clone, Copy)] +struct SearchParameters { + k: NonZeroUsize, +} + +/// Additional metrics collected during flat search. +#[derive(Debug, Clone, Copy)] +struct Metrics { + /// The number of distance comparisons performed. + pub comparisons: u32, +} + +impl search::Search for Searcher +where + T: VectorRepr, +{ + type Id = u32; + type Parameters = SearchParameters; + type Output = Metrics; + + fn num_queries(&self) -> usize { + self.queries.nrows() + } + + fn id_count(&self, parameters: &Self::Parameters) -> search::IdCount { + search::IdCount::Fixed(parameters.k) + } + + async fn search( + &self, + parameters: &Self::Parameters, + buffer: &mut O, + index: usize, + ) -> ANNResult + where + O: SearchOutputBuffer + Send, + { + let context = DefaultContext; + let query = self.queries.row(index); + + let stats = self + .index + .knn_search( + parameters.k, + &self.strategy, + CopyIds, + &context, + query, + buffer, + ) + .await?; + + Ok(Metrics { + comparisons: stats.cmps, + }) + } +} + +////////////////// +// Aggregation // +////////////////// + +/// Aggregates results from multiple flat search runs, computing recall metrics. +struct Aggregator<'a> { + groundtruth: &'a Matrix, + recall_k: usize, +} + +impl<'a> Aggregator<'a> { + fn new(groundtruth: &'a Matrix, recall_k: usize) -> Self { + Self { + groundtruth, + recall_k, + } + } +} + +/// Results of a single flat search run. +#[derive(Debug, Clone, Serialize)] +struct SearchResults { + num_tasks: usize, + k: usize, + qps: Vec, + search_latencies: Vec, + mean_latencies: Vec, + p90_latencies: Vec, + p99_latencies: Vec, + recall: RecallMetrics, + mean_cmps: f32, +} + +impl search::Aggregate for Aggregator<'_> { + type Output = SearchResults; + + fn aggregate( + &mut self, + run: search::Run, + mut results: Vec>, + ) -> anyhow::Result { + // Compute recall using the first repetition's results. + let recall = match results.first() { + Some(first) => benchmark_core::recall::knn( + self.groundtruth, + None, + first.ids().as_rows(), + self.recall_k, + run.parameters().k.get(), + GroundTruthMode::Fixed, + )?, + None => anyhow::bail!("Results must be non-empty"), + }; + + let mut mean_latencies = Vec::with_capacity(results.len()); + let mut p90_latencies = Vec::with_capacity(results.len()); + let mut p99_latencies = Vec::with_capacity(results.len()); + + for r in results.iter_mut() { + let percentiles::Percentiles { mean, p90, p99, .. } = + percentiles::compute_percentiles(r.latencies_mut())?; + mean_latencies.push(mean); + p90_latencies.push(p90); + p99_latencies.push(p99); + } + + let qps: Vec = results + .iter() + .map(|r| recall.num_queries as f64 / r.end_to_end_latency().as_seconds()) + .collect(); + + let mean_cmps = benchmark_core::utils::average_all( + results + .iter() + .flat_map(|r| r.output().iter().map(|o| o.comparisons)), + ) as f32; + + Ok(SearchResults { + num_tasks: run.setup().tasks.into(), + k: run.parameters().k.get(), + qps, + search_latencies: results.iter().map(|r| r.end_to_end_latency()).collect(), + mean_latencies, + p90_latencies, + p99_latencies, + recall: (&recall).into(), + mean_cmps, + }) + } +} + +////////////// +// Results // +////////////// + +#[derive(Debug, Serialize)] +struct FlatResult { + results: Vec, +} + +impl std::fmt::Display for FlatResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.results.is_empty() { + return Ok(()); + } + + let headers: &[&str] = &[ + "K", + "Avg cmps", + "QPS - mean(max)", + "Avg Latency", + "p99 Latency", + "Recall", + "Threads", + ]; + + let mut table = + diskann_benchmark_runner::utils::fmt::Table::new(headers, self.results.len()); + for (i, r) in self.results.iter().enumerate() { + let mut row = table.row(i); + row.insert(r.k, 0); + row.insert(r.mean_cmps, 1); + row.insert( + format!( + "{:.1} ({:.1})", + utils::MaybeDisplay(percentiles::mean(&r.qps), "missing"), + utils::MaybeDisplay(percentiles::max_f64(&r.qps), "missing"), + ), + 2, + ); + row.insert( + format!( + "{:.1}us ({:.1}us)", + utils::MaybeDisplay(percentiles::mean(&r.mean_latencies), "missing"), + utils::MaybeDisplay(percentiles::max_f64(&r.mean_latencies), "missing"), + ), + 3, + ); + row.insert( + format!( + "{:.1}us ({:.1})", + utils::MaybeDisplay(percentiles::mean(&r.p99_latencies), "missing"), + utils::MaybeDisplay(r.p99_latencies.iter().max(), "missing"), + ), + 4, + ); + row.insert(format!("{:3}", r.recall.average), 5); + row.insert(r.num_tasks, 6); + } + + write!(f, "{}", table) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inputs::Example; + use diskann_benchmark_runner::utils::MicroSeconds; + + fn make_dummy_results(num_results: usize) -> FlatResult { + let results = (0..num_results) + .map(|i| SearchResults { + num_tasks: i + 1, + k: 10, + qps: vec![100.0], + search_latencies: vec![MicroSeconds::new(1000)], + mean_latencies: vec![10.0], + p90_latencies: vec![MicroSeconds::new(900)], + p99_latencies: vec![MicroSeconds::new(990)], + recall: RecallMetrics { + recall_k: 10, + recall_n: 10, + num_queries: 100, + average: 0.95, + }, + mean_cmps: 256.0, + }) + .collect(); + FlatResult { results } + } + + #[test] + fn display_empty_flat_result() { + let result = FlatResult { + results: Vec::new(), + }; + let text = format!("{}", result); + assert!(text.is_empty()); + } + + #[test] + fn display_flat_result_with_data() { + let result = make_dummy_results(1); + let text = format!("{}", result); + assert!(text.contains("K")); + assert!(text.contains("Recall")); + } + + #[test] + fn description_with_matching_type() { + let benchmark = Flat::::new(); + let input = crate::inputs::flat::FlatSearch::example(); + let text = format!("{}", DescriptionHelper(&benchmark, Some(&input))); + // When the type matches, description writes nothing (is_match() == true) + assert!(!text.contains("Data Type:")); + } + + #[test] + fn description_without_input() { + let benchmark = Flat::::new(); + let text = format!("{}", DescriptionHelper::(&benchmark, None)); + assert!(text.contains("Data Type: float32")); + } + + #[test] + fn description_with_mismatched_type() { + use diskann_benchmark_runner::utils::datatype::DataType; + let benchmark = Flat::::new(); + let mut input = crate::inputs::flat::FlatSearch::example(); + input.data_type = DataType::UInt8; + let text = format!("{}", DescriptionHelper(&benchmark, Some(&input))); + assert!(text.contains("Data Type: expected \"float32\" but found \"uint8\"")); + } + + /// Helper to call `description()` via `Display`. + struct DescriptionHelper<'a, T: VectorRepr + AsDataType>( + &'a Flat, + Option<&'a crate::inputs::flat::FlatSearch>, + ); + + impl std::fmt::Display for DescriptionHelper<'_, T> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.description(f, self.1) + } + } +} diff --git a/diskann-benchmark/src/inputs/flat.rs b/diskann-benchmark/src/inputs/flat.rs new file mode 100644 index 000000000..d25e77cf7 --- /dev/null +++ b/diskann-benchmark/src/inputs/flat.rs @@ -0,0 +1,160 @@ +/* + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +use std::num::NonZeroUsize; + +use anyhow::Context; +use diskann_benchmark_runner::{files::InputFile, utils::datatype::DataType, Checker}; +use serde::{Deserialize, Serialize}; + +use crate::{ + inputs::{as_input, write_field, Example, PRINT_WIDTH}, + utils::SimilarityMeasure, +}; + +////////////// +// Registry // +////////////// + +as_input!(FlatSearch); + +/////////// +// Input // +/////////// + +/// Input specification for a flat-index (brute-force kNN) benchmark. +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct FlatSearch { + /// Path to the dataset vectors (`.bin` format). + pub(crate) data: InputFile, + + /// The on-disk data type of the dataset. + pub(crate) data_type: DataType, + + /// The distance metric to use. + pub(crate) distance: SimilarityMeasure, + + /// Search configuration. + pub(crate) search: SearchPhase, +} + +impl FlatSearch { + pub(crate) const fn tag() -> &'static str { + "flat-search" + } + + pub(crate) fn validate(&mut self, checker: &mut Checker) -> anyhow::Result<()> { + self.data.resolve(checker)?; + self.search.validate(checker)?; + Ok(()) + } +} + +impl std::fmt::Display for FlatSearch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write_field!(f, "Data", self.data.display())?; + write_field!(f, "Data Type", self.data_type)?; + write_field!(f, "Distance", self.distance)?; + write_field!(f, "Queries", self.search.queries.display())?; + write_field!(f, "Groundtruth", self.search.groundtruth.display())?; + write_field!(f, "K", self.search.k)?; + write_field!( + f, + "Threads", + self.search + .num_threads + .iter() + .map(|t| t.to_string()) + .collect::>() + .join(", ") + )?; + write_field!(f, "Reps", self.search.reps)?; + Ok(()) + } +} + +impl Example for FlatSearch { + fn example() -> Self { + Self { + data: InputFile::new("path/to/data.bin"), + data_type: DataType::Float32, + distance: SimilarityMeasure::SquaredL2, + search: SearchPhase::example(), + } + } +} + +/////////////////// +// Search Phase // +/////////////////// + +/// Parameters controlling the search phase of a flat benchmark. +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SearchPhase { + /// Path to the query vectors (`.bin` format). + pub(crate) queries: InputFile, + + /// Path to the groundtruth file (`.bin` format). + pub(crate) groundtruth: InputFile, + + /// The number of nearest neighbors to retrieve per query. + pub(crate) k: NonZeroUsize, + + /// Number of threads to use for parallel query execution. + pub(crate) num_threads: Vec, + + /// Number of repetitions per configuration for stable timing. + pub(crate) reps: NonZeroUsize, +} + +impl SearchPhase { + pub(crate) fn validate(&mut self, checker: &mut Checker) -> anyhow::Result<()> { + self.queries + .resolve(checker) + .context("resolving queries file")?; + self.groundtruth + .resolve(checker) + .context("resolving groundtruth file")?; + Ok(()) + } +} + +impl Example for SearchPhase { + fn example() -> Self { + Self { + queries: InputFile::new("path/to/queries.bin"), + groundtruth: InputFile::new("path/to/groundtruth.bin"), + k: NonZeroUsize::new(10).unwrap(), + num_threads: vec![ + NonZeroUsize::new(1).unwrap(), + NonZeroUsize::new(4).unwrap(), + NonZeroUsize::new(8).unwrap(), + ], + reps: NonZeroUsize::new(5).unwrap(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::inputs::Example; + + #[test] + fn example_flat_search_round_trips() { + let example = FlatSearch::example(); + let json = serde_json::to_value(&example).unwrap(); + let _: FlatSearch = serde_json::from_value(json).unwrap(); + } + + #[test] + fn display_flat_search() { + let example = FlatSearch::example(); + let text = format!("{}", example); + assert!(text.contains("Data")); + assert!(text.contains("Threads")); + assert!(text.contains("Reps")); + } +} diff --git a/diskann-benchmark/src/inputs/mod.rs b/diskann-benchmark/src/inputs/mod.rs index ed49f145e..e21375a51 100644 --- a/diskann-benchmark/src/inputs/mod.rs +++ b/diskann-benchmark/src/inputs/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod disk; pub(crate) mod exhaustive; pub(crate) mod filters; +pub(crate) mod flat; pub(crate) mod graph_index; pub(crate) mod multi_vector; pub(crate) mod save_and_load; diff --git a/diskann-benchmark/src/main.rs b/diskann-benchmark/src/main.rs index 873b99a54..578c1ca2c 100644 --- a/diskann-benchmark/src/main.rs +++ b/diskann-benchmark/src/main.rs @@ -6,6 +6,7 @@ mod disk_index; mod exhaustive; mod filters; +mod flat; mod index; mod inputs; mod multi_vector; @@ -50,6 +51,7 @@ impl Cli { let mut registry = runner::Registry::new(); exhaustive::register_benchmarks(&mut registry)?; disk_index::register_benchmarks(&mut registry)?; + flat::register_benchmarks(&mut registry)?; index::register_benchmarks(&mut registry)?; filters::register_benchmarks(&mut registry)?; multi_vector::register_benchmarks(&mut registry)?; @@ -270,7 +272,7 @@ mod tests { let tempdir = tempfile::tempdir().unwrap(); - let input_path = tempdir.path().join("graph-index.json"); + let input_path = tempdir.path().join("input.json"); save_to_file(&input_path, &raw); let output_path = tempdir.path().join("output.json"); @@ -308,6 +310,16 @@ mod tests { run_integration_test(raw); } + ///////////////////////// + // Flat Search // + ///////////////////////// + + #[test] + fn flat_search_integration() { + let raw = value_from_file(&example_directory().join("flat-index.json")); + run_integration_test(raw); + } + //////////////////////////// // Dynamic Index // ////////////////////////////