1use std::collections::BTreeMap;
4use std::future::Future;
5use std::marker::PhantomData;
6use std::num::NonZeroUsize;
7
8pub const MAX_MATERIALIZATION_CANDIDATES: usize = 4_096;
10pub const MAX_MATERIALIZATION_LOADER_BATCH: usize = 256;
12pub const MAX_MATERIALIZATION_OUTPUTS: usize = 4_096;
14pub const MAX_MATERIALIZATION_DIAGNOSTICS: usize = 4_096;
16pub const MAX_MATERIALIZATION_DROP_REASONS: usize = 32;
18
19#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
21#[non_exhaustive]
22pub enum MaterializationLimitError {
23 #[error("materialization {field} limit {requested} exceeds v1 maximum {maximum}")]
25 AboveV1Maximum {
26 field: &'static str,
28 requested: usize,
30 maximum: usize,
32 },
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
42pub struct MaterializationLimits {
43 max_candidates: usize,
44 max_loader_batch_size: NonZeroUsize,
45 max_output_rows: usize,
46 max_diagnostic_details: usize,
47}
48
49impl MaterializationLimits {
50 pub fn try_new(
52 max_candidates: usize,
53 max_loader_batch_size: NonZeroUsize,
54 max_output_rows: usize,
55 max_diagnostic_details: usize,
56 ) -> Result<Self, MaterializationLimitError> {
57 for (field, requested, maximum) in [
58 ("candidates", max_candidates, MAX_MATERIALIZATION_CANDIDATES),
59 (
60 "loader_batch_size",
61 max_loader_batch_size.get(),
62 MAX_MATERIALIZATION_LOADER_BATCH,
63 ),
64 ("output_rows", max_output_rows, MAX_MATERIALIZATION_OUTPUTS),
65 (
66 "diagnostic_details",
67 max_diagnostic_details,
68 MAX_MATERIALIZATION_DIAGNOSTICS,
69 ),
70 ] {
71 if requested > maximum {
72 return Err(MaterializationLimitError::AboveV1Maximum {
73 field,
74 requested,
75 maximum,
76 });
77 }
78 }
79 Ok(Self {
80 max_candidates,
81 max_loader_batch_size,
82 max_output_rows,
83 max_diagnostic_details,
84 })
85 }
86
87 pub const fn max_candidates(self) -> usize {
89 self.max_candidates
90 }
91
92 pub const fn max_loader_batch_size(self) -> NonZeroUsize {
94 self.max_loader_batch_size
95 }
96
97 pub const fn max_output_rows(self) -> usize {
99 self.max_output_rows
100 }
101
102 pub const fn max_diagnostic_details(self) -> usize {
104 self.max_diagnostic_details
105 }
106}
107
108#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct RankedCandidate<Key, Score> {
111 pub key: Key,
113 pub score: Score,
115}
116
117pub trait DropReason: Copy + Eq + 'static {
119 const ALL: &'static [Self];
121
122 fn ordinal(self) -> usize;
124}
125
126#[derive(Debug, PartialEq, Eq)]
128pub enum MaterializationDecision<Output, Reason, Error> {
129 Keep(Output),
131 Drop(Reason),
133 Fatal(Error),
135}
136
137#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct MaterializedItem<Key, Score, Output> {
140 pub candidate: RankedCandidate<Key, Score>,
142 pub rank: usize,
144 pub output: Output,
146}
147
148#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct DropDiagnostic<Key, Score, Reason> {
151 pub candidate: RankedCandidate<Key, Score>,
153 pub reason: Reason,
155}
156
157#[derive(Clone, Debug, PartialEq, Eq)]
159pub struct DropCounts<Reason> {
160 counts: [usize; MAX_MATERIALIZATION_DROP_REASONS],
161 marker: PhantomData<Reason>,
162}
163
164impl<Reason: DropReason> DropCounts<Reason> {
165 pub fn count(&self, reason: Reason) -> Option<usize> {
170 let ordinal = reason.ordinal();
171 Reason::ALL
172 .get(ordinal)
173 .filter(|declared| **declared == reason)
174 .map(|_| self.counts[ordinal])
175 }
176
177 pub fn total(&self) -> usize {
179 self.counts.iter().sum()
180 }
181}
182
183#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct MaterializedPrefix<Key, Score, Output, Reason> {
186 pub accepted: Vec<MaterializedItem<Key, Score, Output>>,
188 pub drop_counts: DropCounts<Reason>,
190 pub diagnostic_details: Vec<DropDiagnostic<Key, Score, Reason>>,
192 pub diagnostics_truncated: bool,
194}
195
196#[derive(Debug, PartialEq, Eq, thiserror::Error)]
198#[non_exhaustive]
199pub enum MaterializationError<Error> {
200 #[error("materialization request {field} {requested} exceeds configured limit {limit}")]
202 RequestExceedsLimit {
203 field: &'static str,
205 requested: usize,
207 limit: usize,
209 },
210 #[error("duplicate materialization candidate at indexes {first_index} and {duplicate_index}")]
212 DuplicateCandidate {
213 first_index: usize,
215 duplicate_index: usize,
217 },
218 #[error(
220 "materialization candidate order is not strict at indexes {previous_index} and {index}"
221 )]
222 NonMonotonicOrder {
223 previous_index: usize,
225 index: usize,
227 },
228 #[error("invalid materialization drop taxonomy: {message}")]
230 InvalidDropTaxonomy {
231 message: &'static str,
233 },
234 #[error("materialization loader returned an unexpected key")]
236 UnexpectedLoaderKey,
237 #[error("materialization loader returned a duplicate key")]
239 DuplicateLoaderKey,
240 #[error(
242 "materialization loader returned {returned} rows for a batch of {requested} candidates"
243 )]
244 LoaderReturnedTooManyRows {
245 returned: usize,
247 requested: usize,
249 },
250 #[error("materialization caller callback failed")]
252 Caller(Error),
253}
254
255#[allow(clippy::too_many_arguments)]
275pub async fn materialize_ranked_prefix<
276 Key,
277 Score,
278 OrderKey,
279 Row,
280 Output,
281 Reason,
282 Error,
283 OrderFn,
284 Validator,
285 Loader,
286 LoaderFuture,
287 Classifier,
288>(
289 candidates: Vec<RankedCandidate<Key, Score>>,
290 output_limit: usize,
291 loader_batch_size: NonZeroUsize,
292 limits: MaterializationLimits,
293 mut order_key: OrderFn,
294 mut candidate_validator: Validator,
295 mut batch_loader: Loader,
296 mut classifier: Classifier,
297) -> Result<MaterializedPrefix<Key, Score, Output, Reason>, MaterializationError<Error>>
298where
299 Key: Clone + Ord,
300 OrderKey: Ord,
301 Reason: DropReason,
302 OrderFn: FnMut(&RankedCandidate<Key, Score>) -> OrderKey,
303 Validator: FnMut(&RankedCandidate<Key, Score>) -> Result<(), Error>,
304 Loader: FnMut(Vec<Key>) -> LoaderFuture,
305 LoaderFuture: Future<Output = Result<Vec<(Key, Row)>, Error>>,
306 Classifier: FnMut(
307 &RankedCandidate<Key, Score>,
308 Option<Row>,
309 ) -> MaterializationDecision<Output, Reason, Error>,
310{
311 for (field, requested, limit) in [
312 ("candidates", candidates.len(), limits.max_candidates()),
313 (
314 "loader_batch_size",
315 loader_batch_size.get(),
316 limits.max_loader_batch_size().get(),
317 ),
318 ("output_rows", output_limit, limits.max_output_rows()),
319 ] {
320 if requested > limit {
321 return Err(MaterializationError::RequestExceedsLimit {
322 field,
323 requested,
324 limit,
325 });
326 }
327 }
328
329 if Reason::ALL.len() > MAX_MATERIALIZATION_DROP_REASONS {
330 return Err(MaterializationError::InvalidDropTaxonomy {
331 message: "drop taxonomy exceeds 32 variants",
332 });
333 }
334 for (ordinal, reason) in Reason::ALL.iter().copied().enumerate() {
335 if reason.ordinal() != ordinal {
336 return Err(MaterializationError::InvalidDropTaxonomy {
337 message: "drop taxonomy ordinals are not contiguous and ordered",
338 });
339 }
340 }
341
342 {
345 let mut first_indexes = BTreeMap::<&Key, usize>::new();
346 let mut previous_order = None;
347 for (index, candidate) in candidates.iter().enumerate() {
348 if let Some(first_index) = first_indexes.insert(&candidate.key, index) {
349 return Err(MaterializationError::DuplicateCandidate {
350 first_index,
351 duplicate_index: index,
352 });
353 }
354
355 let current_order = order_key(candidate);
356 if previous_order
357 .as_ref()
358 .is_some_and(|previous| previous >= ¤t_order)
359 {
360 return Err(MaterializationError::NonMonotonicOrder {
361 previous_index: index - 1,
362 index,
363 });
364 }
365 previous_order = Some(current_order);
366 }
367 }
368
369 let accepted_capacity = output_limit.min(candidates.len());
370 let diagnostic_capacity = limits.max_diagnostic_details().min(candidates.len());
371 let mut accepted = Vec::with_capacity(accepted_capacity);
372 let mut diagnostic_details = Vec::with_capacity(diagnostic_capacity);
373 let mut counts = [0_usize; MAX_MATERIALIZATION_DROP_REASONS];
374 let mut diagnostics_truncated = false;
375 let mut remaining = candidates.into_iter();
376
377 while accepted.len() < output_limit {
378 let batch = remaining
379 .by_ref()
380 .take(loader_batch_size.get())
381 .collect::<Vec<_>>();
382 if batch.is_empty() {
383 break;
384 }
385
386 for candidate in &batch {
387 candidate_validator(candidate).map_err(MaterializationError::Caller)?;
388 }
389
390 let loader_keys = batch
391 .iter()
392 .map(|candidate| candidate.key.clone())
393 .collect::<Vec<_>>();
394 let rows = batch_loader(loader_keys)
395 .await
396 .map_err(MaterializationError::Caller)?;
397 if rows.len() > batch.len() {
398 return Err(MaterializationError::LoaderReturnedTooManyRows {
399 returned: rows.len(),
400 requested: batch.len(),
401 });
402 }
403
404 let mut indexes = BTreeMap::<&Key, usize>::new();
407 for (index, candidate) in batch.iter().enumerate() {
408 indexes.insert(&candidate.key, index);
409 }
410 let mut row_slots = (0..batch.len()).map(|_| None).collect::<Vec<Option<Row>>>();
411 let mut saw_unexpected = false;
412 let mut saw_duplicate = false;
413 for (key, row) in rows {
414 match indexes.get(&key).copied() {
415 Some(index) if row_slots[index].is_none() => row_slots[index] = Some(row),
416 Some(_) => saw_duplicate = true,
417 None => saw_unexpected = true,
418 }
419 }
420 drop(indexes);
421 if saw_unexpected {
422 return Err(MaterializationError::UnexpectedLoaderKey);
423 }
424 if saw_duplicate {
425 return Err(MaterializationError::DuplicateLoaderKey);
426 }
427
428 for (candidate, row) in batch.into_iter().zip(row_slots) {
429 if accepted.len() == output_limit {
430 break;
431 }
432 match classifier(&candidate, row) {
433 MaterializationDecision::Keep(output) => {
434 accepted.push(MaterializedItem {
435 candidate,
436 rank: accepted.len() + 1,
437 output,
438 });
439 }
440 MaterializationDecision::Drop(reason) => {
441 let ordinal = reason.ordinal();
442 if Reason::ALL.get(ordinal).copied() != Some(reason) {
443 return Err(MaterializationError::InvalidDropTaxonomy {
444 message: "classifier returned an undeclared drop reason",
445 });
446 }
447 counts[ordinal] += 1;
448 if diagnostic_details.len() < limits.max_diagnostic_details() {
449 diagnostic_details.push(DropDiagnostic { candidate, reason });
450 } else {
451 diagnostics_truncated = true;
452 }
453 }
454 MaterializationDecision::Fatal(error) => {
455 return Err(MaterializationError::Caller(error));
456 }
457 }
458 }
459 }
460
461 for candidate in remaining {
464 candidate_validator(&candidate).map_err(MaterializationError::Caller)?;
465 }
466
467 Ok(MaterializedPrefix {
468 accepted,
469 drop_counts: DropCounts {
470 counts,
471 marker: PhantomData,
472 },
473 diagnostic_details,
474 diagnostics_truncated,
475 })
476}
477
478#[cfg(test)]
479#[path = "materialization_tests.rs"]
480mod tests;