Skip to main content

khive_retrieval/
materialization.rs

1//! Policy-free materialization of a bounded ranked candidate prefix.
2
3use std::collections::BTreeMap;
4use std::future::Future;
5use std::marker::PhantomData;
6use std::num::NonZeroUsize;
7
8/// Absolute v1 candidate ceiling.
9pub const MAX_MATERIALIZATION_CANDIDATES: usize = 4_096;
10/// Absolute v1 loader-batch ceiling.
11pub const MAX_MATERIALIZATION_LOADER_BATCH: usize = 256;
12/// Absolute v1 accepted-output ceiling.
13pub const MAX_MATERIALIZATION_OUTPUTS: usize = 4_096;
14/// Absolute v1 retained-diagnostic ceiling.
15pub const MAX_MATERIALIZATION_DIAGNOSTICS: usize = 4_096;
16/// Absolute v1 drop-taxonomy ceiling.
17pub const MAX_MATERIALIZATION_DROP_REASONS: usize = 32;
18
19/// Construction failure for [`MaterializationLimits`].
20#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
21#[non_exhaustive]
22pub enum MaterializationLimitError {
23    /// A configured ceiling exceeds the portable v1 envelope.
24    #[error("materialization {field} limit {requested} exceeds v1 maximum {maximum}")]
25    AboveV1Maximum {
26        /// Limit field that failed validation.
27        field: &'static str,
28        /// Requested value.
29        requested: usize,
30        /// Absolute v1 maximum.
31        maximum: usize,
32    },
33}
34
35/// Caller-selected ceilings for one materialization consumer.
36///
37/// Private fields ensure every instance has passed [`Self::try_new`].
38/// These bounds cover controller-owned collections; caller-owned key, score,
39/// row and output payloads, and allocations inside callbacks, remain caller
40/// responsibilities.
41#[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    /// Validate and construct consumer-specific limits.
51    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    /// Maximum supplied candidates for this consumer.
88    pub const fn max_candidates(self) -> usize {
89        self.max_candidates
90    }
91
92    /// Maximum rows in one loader callback.
93    pub const fn max_loader_batch_size(self) -> NonZeroUsize {
94        self.max_loader_batch_size
95    }
96
97    /// Maximum accepted output rows.
98    pub const fn max_output_rows(self) -> usize {
99        self.max_output_rows
100    }
101
102    /// Maximum retained per-drop diagnostic details.
103    pub const fn max_diagnostic_details(self) -> usize {
104        self.max_diagnostic_details
105    }
106}
107
108/// One unique candidate in caller-provided total order.
109#[derive(Clone, Debug, PartialEq, Eq)]
110pub struct RankedCandidate<Key, Score> {
111    /// Correlation key used by the loader.
112    pub key: Key,
113    /// Caller-owned score retained unchanged on acceptance.
114    pub score: Score,
115}
116
117/// Closed caller-owned drop taxonomy.
118pub trait DropReason: Copy + Eq + 'static {
119    /// Every variant exactly once, in ordinal order.
120    const ALL: &'static [Self];
121
122    /// Fixed zero-based index into [`Self::ALL`], determined only by `self`.
123    fn ordinal(self) -> usize;
124}
125
126/// Pack policy decision for one correlated candidate row.
127#[derive(Debug, PartialEq, Eq)]
128pub enum MaterializationDecision<Output, Reason, Error> {
129    /// Retain the output and assign the next compact rank.
130    Keep(Output),
131    /// Omit the candidate and record a typed diagnostic.
132    Drop(Reason),
133    /// Stop immediately with the caller's error.
134    Fatal(Error),
135}
136
137/// One retained accepted output.
138#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct MaterializedItem<Key, Score, Output> {
140    /// Original candidate, including its unchanged score.
141    pub candidate: RankedCandidate<Key, Score>,
142    /// Compact one-based result rank.
143    pub rank: usize,
144    /// Caller-owned output projection.
145    pub output: Output,
146}
147
148/// One retained per-candidate drop diagnostic.
149#[derive(Clone, Debug, PartialEq, Eq)]
150pub struct DropDiagnostic<Key, Score, Reason> {
151    /// Original candidate in input order.
152    pub candidate: RankedCandidate<Key, Score>,
153    /// Caller-owned typed reason.
154    pub reason: Reason,
155}
156
157/// Fixed-capacity aggregate counters keyed by a caller's drop taxonomy.
158#[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    /// Count drops for one reason.
166    ///
167    /// Returns `None` when `reason` is not a declared member of the closed
168    /// taxonomy used to construct this result.
169    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    /// Count all drops.
178    pub fn total(&self) -> usize {
179        self.counts.iter().sum()
180    }
181}
182
183/// Successful bounded materialization result.
184#[derive(Clone, Debug, PartialEq, Eq)]
185pub struct MaterializedPrefix<Key, Score, Output, Reason> {
186    /// Accepted outputs in original candidate order.
187    pub accepted: Vec<MaterializedItem<Key, Score, Output>>,
188    /// Aggregate typed drop counts.
189    pub drop_counts: DropCounts<Reason>,
190    /// First bounded drop details in candidate order.
191    pub diagnostic_details: Vec<DropDiagnostic<Key, Score, Reason>>,
192    /// Whether at least one detail was omitted by the configured detail cap.
193    pub diagnostics_truncated: bool,
194}
195
196/// Structural or caller failure from ranked-prefix materialization.
197#[derive(Debug, PartialEq, Eq, thiserror::Error)]
198#[non_exhaustive]
199pub enum MaterializationError<Error> {
200    /// One request dimension exceeds its consumer-specific limit.
201    #[error("materialization request {field} {requested} exceeds configured limit {limit}")]
202    RequestExceedsLimit {
203        /// Request dimension.
204        field: &'static str,
205        /// Requested value.
206        requested: usize,
207        /// Consumer limit.
208        limit: usize,
209    },
210    /// A candidate key occurs more than once.
211    #[error("duplicate materialization candidate at indexes {first_index} and {duplicate_index}")]
212    DuplicateCandidate {
213        /// First occurrence.
214        first_index: usize,
215        /// Duplicate occurrence.
216        duplicate_index: usize,
217    },
218    /// Caller-provided order keys are not strictly increasing.
219    #[error(
220        "materialization candidate order is not strict at indexes {previous_index} and {index}"
221    )]
222    NonMonotonicOrder {
223        /// Previous index.
224        previous_index: usize,
225        /// Current index.
226        index: usize,
227    },
228    /// The caller's drop taxonomy is not a complete ordinal sequence.
229    #[error("invalid materialization drop taxonomy: {message}")]
230    InvalidDropTaxonomy {
231        /// Stable validation explanation.
232        message: &'static str,
233    },
234    /// A loader returned a key outside its requested batch.
235    #[error("materialization loader returned an unexpected key")]
236    UnexpectedLoaderKey,
237    /// A loader returned one key more than once.
238    #[error("materialization loader returned a duplicate key")]
239    DuplicateLoaderKey,
240    /// A loader returned more rows than the requested batch could contain.
241    #[error(
242        "materialization loader returned {returned} rows for a batch of {requested} candidates"
243    )]
244    LoaderReturnedTooManyRows {
245        /// Number of keyed rows returned by the loader.
246        returned: usize,
247        /// Number of keys supplied to the loader.
248        requested: usize,
249    },
250    /// Candidate validation, loading, or classification failed.
251    #[error("materialization caller callback failed")]
252    Caller(Error),
253}
254
255/// Materialize a bounded prefix of an already total-ordered candidate list.
256///
257/// `order_key` must expose the caller's existing **strictly increasing** total
258/// order. For a descending score order, wrap the score in [`std::cmp::Reverse`]
259/// and include the caller's stable ID tie-break. The controller validates the
260/// complete bounded order and closed drop taxonomy before invoking the other
261/// callbacks.
262///
263/// For each ordered batch, `candidate_validator` runs for every candidate
264/// before `batch_loader` receives that batch's keys. The loader may return
265/// keyed rows in arbitrary order and may omit keys; a missing key is passed to
266/// `classifier` as `None`. Duplicate, unexpected, or excess rows are fatal
267/// structural errors checked before any row in the batch is classified.
268///
269/// Classification stops at the `output_limit`th `Keep`. Later rows already
270/// returned in that batch are ignored, while `candidate_validator` still
271/// visits the remaining tail without further loader or classifier calls. The
272/// validator and classifier are policy-only synchronous callbacks; all caller
273/// I/O belongs in the bounded async loader.
274#[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    // Validate the complete bounded input before any validator or loader work.
343    // Borrowed map keys avoid cloning caller-owned candidate payloads.
344    {
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 >= &current_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        // Correlate the entire returned batch before classifying any row.  The
405        // index borrows candidate keys and the row slots stay batch-bounded.
406        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    // The loaded batch that produced K was already validated in full.  Only
462    // candidates beyond that batch remain here, and they must never trigger I/O.
463    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;