tidaldb/tidal/src/query/executor/pipeline.rs
jordan cdbe9cb453 Merge remote-tracking branch 'origin/main' (m11/m12 cluster) into m9/m10
Reconciles two independently-developed lines from base 006d3d0:
  ours   — M9/M10 community layers, retroactive purge + re-materialization,
           signal revocation, agent capability boundaries, P1 feedback loop,
           reason labels, instrumented metrics
  theirs — M11/M12 cluster mode (tidal-net gRPC transport, tidal-server
           cluster/scatter-gather, tidal-stress), multi-vector preference,
           ANN candidate-gen, warm-tier day buckets, keyed signal snapshots

Notable semantic resolutions:

* storage::keys::Tag — both sides allocated 0x0E..0x11 for different
  records. Kept theirs' 0x0E..0x1A (shipped on-disk format) and renumbered
  ours to 0x1B..0x1E (CommunityMembership/Revocation/PurgeManifest/
  CommunityLeave); Tag::ALL grown to 30 so the contiguity drift guard holds.

* ranking executor — took theirs' rewrite (SignalReadPlan pre-pass, keyed
  SignalKey snapshots, Result-returning reads, finalize()) and re-applied
  ours' M10 read suppression at the chokepoints it introduced:
  single_signal_score, score_hot/trending/controversial,
  CreatorEngagementRate, and the Stage-4 boost loop.

* signals::warm — theirs' day-bucket/read-time-rotation rewrite, with ours'
  subtract_bucket and Clone extended to the new day tier; ours' test split
  kept (warm/tests.rs, warm/proptests.rs) carrying theirs' updated bodies.

* db::signals — kept ours' contribution-logging try_cohort_attribution in
  signal_dispatch.rs and theirs' event-time try_update_preference_vector;
  dropped the superseded duplicates.

* db::mod / from_parts — theirs' constructors, with ours' purge/
  re-materialization/revocation/community/skip-counter fields and restart
  rebuilds; from_parts kept in its own file per the 600-line guideline.

* schema::validation::builders — ours' module split with theirs' expanded
  tests; policy validation runs both sides' checks (read-signal lists +
  profile overrides, then the zero-duration limit guard).

* feedback Unhide no longer writes a -1.0 "hide" signal: theirs' engine
  rejects negative weights (spec §8). Reverses index state only, matching
  every other undo action.

* SessionState::new is now the single construction path (gains
  overrides_rejected/default_profile); AuditEntry gains kind on the
  deserialize path, inferred from the accepted flag as before.

* Removed tidal/src/replication/tcp_transport.rs and its test: never
  declared in replication/mod.rs on either branch, so it had never
  compiled and nothing referenced it. Superseded by tidal-net's
  GrpcTransport.

Verified: cargo clippy -p tidaldb (lib) clean; --all-targets compiles for
tidaldb/tidal-net/tidal-server/tidal-stress; 2094/2094 lib tests and the
integration suite pass except m8p3_reconcile_production's two CRDT-count
assertions, which fail identically on MERGE_HEAD (pre-existing).
tidalctl cannot build locally: its aws-sdk deps need rustc 1.91.1, local
toolchain is 1.91.0.
2026-08-03 02:16:04 -06:00

923 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! RETRIEVE query execution pipeline.
//!
//! Contains the `execute()` method that drives the 6-stage pipeline.
//! Stage 3 (signal scoring) is delegated to `stage3_score` in `mod.rs`.
#![allow(clippy::too_many_lines)]
use std::{collections::HashSet, time::Instant};
use super::{RetrieveExecutor, candidate_gen, post_filter, user_filter};
use crate::{
query::{
retrieve::{Cursor, QueryError, Results, Retrieve, RetrieveResult, Signal},
stats::QueryStats,
},
ranking::{
diversity::DiversitySelector,
profile::{CandidateStrategy, Sort},
},
schema::{EntityId, Timestamp},
storage::indexes::{
bitmap::BitmapIndex,
filter::{FilterEvaluator, FilterResult},
range::RangeIndex,
},
};
/// ANN candidate over-fetch multiplier (m12p2): fetch `limit * this` nearest
/// neighbours so Stage 2/2.5 filtering and Stage 4 diversity can trim back to
/// `limit` without starving. Mirrors the SEARCH ANN over-fetch.
const ANN_OVERFETCH: usize = 10;
/// Minimum ANN candidate count regardless of `limit`, so a tiny-limit feed still
/// seeds a usable candidate pool.
const ANN_CANDIDATE_FLOOR: usize = 200;
impl RetrieveExecutor<'_> {
/// Execute a RETRIEVE query through the 6-stage pipeline.
///
/// # Errors
///
/// Returns `QueryError` on validation failure, missing profile, or
/// unsupported candidate strategy.
#[allow(clippy::too_many_lines)]
pub fn execute(&self, query: &Retrieve) -> Result<Results, QueryError> {
let query_start = Instant::now();
let mut stats = QueryStats::new(query.profile.name.clone());
stats.degradation_level = self.degradation_level as u8;
// Validate the query against the profile registry.
query.validate(self.profile_registry)?;
// Compute the combined filter ONCE and reuse it across every stage.
// `combined_filter()` clones the entire `filters` Vec into a fresh
// `And` node on each call, and the pipeline consults it at 6+ points.
let combined_filter = query.combined_filter();
// Reject filter shapes with no sound semantics BEFORE any stage runs, so
// a bad query fails loudly instead of silently returning the inverse set
// (negated deferred post-filters) or AND-instead-of-OR (any deferred
// post-filter under an OR — they resolve to the full universe in Stage 2
// and are then applied conjunctively, so the OR would collapse to AND).
if let Some(ref filter_expr) = combined_filter {
user_filter::reject_negated_deferred_filters(filter_expr)?;
user_filter::reject_or_of_deferred_filters(filter_expr)?;
}
tracing::trace!(
profile = %query.profile.name,
limit = query.limit,
has_filter = combined_filter.is_some(),
has_diversity = query.diversity.is_some(),
"retrieve query start"
);
// Resolve the profile.
#[allow(clippy::option_if_let_else)]
let profile = match query.profile.version {
Some(v) => self
.profile_registry
.get_version(&query.profile.name, v)
.map_err(|_| QueryError::ProfileNotFound(query.profile.name.clone()))?,
None => self
.profile_registry
.get(&query.profile.name)
.map_err(|_| QueryError::ProfileNotFound(query.profile.name.clone()))?,
};
let mut warnings: Vec<String> = Vec::new();
// ── Stage 1: Candidate Generation ───────────────────────────────
// When for_creator is specified, restrict candidates to that creator's
// items using the CreatorItemsBitmap. This is O(k) where k = the
// creator's item count, avoiding the O(N) scan cap that would miss
// items at high IDs in a large catalog.
//
// The two `for_creator` arms below are kept SEPARATE on purpose. A single
// `if let creator_id && let creator_items` chain would make the whole
// condition false when `for_creator` is set but `creator_items` is absent,
// falling through to the full-universe scan and SILENTLY dropping the
// creator restriction (returning every creator's items). Instead, when the
// scope is requested but cannot be satisfied, we warn and return an EMPTY
// candidate set — the requested scope yields no items rather than the
// inverse (all items). Mirrors the warn-on-missing-index pattern in Stage 2.
let has_user_context = query.for_user.is_some();
let mut candidates = if let Some(creator_id) = query.for_creator {
if let Some(creator_items) = self.creator_items {
creator_items
.get(creator_id.as_u64())
.map_or_else(Vec::new, |bm| {
bm.iter()
.map(|id_u32| EntityId::new(u64::from(id_u32)))
.collect()
})
} else {
warnings.push(
"FOR CREATOR scope requested but the creator-items index is unavailable; \
returning no candidates (refusing to fall back to a full-universe scan \
that would ignore the creator restriction)"
.to_string(),
);
tracing::warn!(
creator_id = creator_id.as_u64(),
"for_creator scope unsatisfiable: creator_items index absent; returning empty"
);
Vec::new()
}
} else {
match &profile.candidate_strategy {
CandidateStrategy::Scan { .. } => {
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
}
CandidateStrategy::SignalRanked { signal, .. } => {
// m12p2: candidates come from the cached per-signal-type top-K
// (O(K)). When the signal is absent from the schema, or no
// writes have landed yet, that yields nothing — fall back to a
// scan so the read still works (e.g. trending on a fresh corpus
// with no views yet) rather than returning an empty feed.
let ranked =
candidate_gen::signal_ranked_candidates(self.ledger, signal, query.limit);
if ranked.is_empty() {
warnings.push(format!(
"SignalRanked('{signal}') produced no candidates \
(signal absent or no writes yet); falling back to scan"
));
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
} else {
ranked
}
}
CandidateStrategy::Ann {
limit: ann_limit, ..
} => {
// (`top_clusters` is consulted by the db layer when it resolves
// the fan-out set into `ann_query_vectors`; the executor just
// runs whatever vectors it was handed.)
// m12p2: ANN candidate generation in RETRIEVE — O(ef_search)
// nearest neighbours of the resolved query vector (the user's
// preference vector for `for_you`, the seed item's embedding
// for `similar_to`), over-fetched so Stage 2/2.5 + diversity
// have room. Degrades to a scan (with a caller- AND
// operator-visible note) when no registry/query vector is
// available — an anonymous read or a user with no preference
// vector yet — so the read keeps working instead of erroring.
// Over-fetch limit×10, floored so tiny feeds still seed a pool,
// and capped by the profile's declared `Ann.limit` so a huge
// page size can't drive an unbounded ANN beam.
let k = query
.limit
.saturating_mul(ANN_OVERFETCH)
.max(ANN_CANDIDATE_FLOOR)
.min((*ann_limit).max(ANN_CANDIDATE_FLOOR));
// Prefer the multi-vector fan-out set (warm `for_you`) when the
// db layer resolved one; fall back to the single query vector
// (`similar_to`, cold-start). Both are BORROWED — no per-request
// clone of the (up to 3 × dim) centroids on the serve path.
// `ann_candidates_multi` is byte-identical to the single search
// when the slice has ≤1 element, so a single-cluster user pays
// no fan-out overhead.
let single_fallback;
let fan_out: &[Vec<f32>] = match &self.ann_query_vectors {
Some(vs) if !vs.is_empty() => vs.as_slice(),
_ => match &self.ann_query_vector {
Some(v) => {
single_fallback = std::slice::from_ref(v);
single_fallback
}
None => &[],
},
};
if let Some(registry) = self.embedding_registry
&& !fan_out.is_empty()
{
let slot = self.item_embedding_slot.unwrap_or("content");
// ef_search=0 → the index default (ANN_DEFAULT_EF_SEARCH),
// matching the m12p2 single-vector path. TODO(tuning):
// thread the per-query ef_search the m12p3 knob resolves so
// the fan-out can trade beam width per cluster (the M-sweep
// open question). Pinned to the default for now.
let ann =
candidate_gen::ann_candidates_multi(registry, slot, fan_out, k, 0);
if ann.is_empty() {
warnings.push(
"ANN candidate generation returned no candidates; \
falling back to scan"
.to_string(),
);
tracing::warn!(
profile = %query.profile.name,
"ANN returned no candidates (empty/absent slot); scan fallback"
);
candidate_gen::scan_candidates(
self.universe,
query.limit,
has_user_context,
)
} else {
ann
}
} else {
warnings.push(
"ANN candidate strategy: no query vector resolvable \
(anonymous read or no preference vector yet); falling back to scan"
.to_string(),
);
tracing::debug!(
profile = %query.profile.name,
has_registry = self.embedding_registry.is_some(),
"ANN strategy without a query vector; scan fallback"
);
candidate_gen::scan_candidates(self.universe, query.limit, has_user_context)
}
}
CandidateStrategy::Relationship => {
// M3: source candidates from the user's followed creators.
// Uses the follows index in user_state + creator_items bitmap to build
// a candidate set from items produced by followed creators.
if let (Some(user_id), Some(user_state), Some(creator_items)) =
(query.for_user, self.user_state, self.creator_items)
{
let followed = user_state.followed_creators_vec(user_id);
if followed.is_empty() {
warnings.push(
"Relationship strategy: user follows no creators; falling back to scan"
.to_string(),
);
candidate_gen::scan_candidates(self.universe, query.limit, true)
} else {
let bitmap = creator_items.union_for(&followed);
if bitmap.is_empty() {
warnings.push(
"Relationship strategy: followed creators have no items; falling back to scan"
.to_string(),
);
candidate_gen::scan_candidates(self.universe, query.limit, true)
} else {
bitmap
.iter()
.map(|id_u32| EntityId::new(u64::from(id_u32)))
.collect()
}
}
} else {
warnings.push(
"Relationship strategy requires FOR USER clause; falling back to scan"
.to_string(),
);
candidate_gen::scan_candidates(self.universe, query.limit, false)
}
}
other => {
return Err(QueryError::UnsupportedStrategy(format!(
"{other:?} requires M4+ infrastructure"
)));
}
}
};
// Apply exclude list.
if !query.exclude.is_empty() {
let exclude_set: HashSet<u64> = query.exclude.iter().map(|id| id.as_u64()).collect();
candidates.retain(|id| !exclude_set.contains(&id.as_u64()));
}
// M7p2: ReducedCandidates — cap scan set to reduce Stage 3 scoring work.
//
// A blind `truncate(cap)` keeps the FIRST `cap` candidates in scan order.
// `scan_candidates` iterates the universe bitmap in ASCENDING entity-ID
// order, so a blind truncate keeps the LOWEST IDs. For an ID-ordered
// profile that ranks by DESCENDING entity ID (e.g. `New`), the highest IDs
// are exactly the items that should rank at the top — so a blind truncate
// drops the top-ranked items before scoring and corrupts the "new"
// ranking. We therefore truncate by the profile's primary ordering key:
// for `New`, keep the `cap` HIGHEST IDs via an O(N) `select_nth_unstable`
// partition (no full sort; Stage 3 still orders the survivors). Other
// profiles score on signal state rather than scan position, so their
// truncation order is not load-bearing and a plain truncate is retained.
if self.degradation_level.reduces_candidates() {
let cap = (query.limit * 4).max(100);
if candidates.len() > cap {
if matches!(profile.sort, Some(Sort::New)) {
// Partition so the `cap` highest IDs occupy [0, cap): the items
// that rank highest under descending-ID order survive.
candidates.select_nth_unstable_by(cap - 1, |a, b| b.as_u64().cmp(&a.as_u64()));
}
candidates.truncate(cap);
}
}
stats.candidates_considered = candidates.len();
tracing::trace!(
candidates = candidates.len(),
"stage 1: candidates generated"
);
// ── Stage 2: Filter Evaluation ──────────────────────────────────
if let Some(ref filter_expr) = combined_filter {
// Resolve the universe up front, BEFORE building the evaluator. The
// `FilterEvaluator` resolves deferred variants
// (`MinSignal`/`MaxSignal`/`NearLocation`/`InCollection`/user-state)
// to `self.universe` and intersects every `And` child against it.
// That is only sound when the universe is a populated, trustworthy
// bitmap. When it is unavailable — not wired (`None`) or the read
// lock is poisoned — the empty fallback would make the intersection
// silently drop EVERY candidate (including ones that should pass)
// before the Stage 2.2/2.3/2.4 deferred post-filters ever run. The
// actual deferred filtering happens in those later stages, so the
// correct degraded behavior — mirroring the SEARCH pipeline's
// `apply_metadata_filter` (W28) — is to SKIP the index intersection
// entirely (retain all) and surface a warning, rather than collapse
// to an empty result set.
#[allow(clippy::option_if_let_else)]
let universe_guard = match self.universe {
Some(u) => {
if let Ok(guard) = u.read() {
Some(guard)
} else {
warnings.push(
"universe lock poisoned; metadata index pre-filter degraded — \
relying on post-filters"
.to_string(),
);
None
}
}
None => None,
};
if let Some(universe_ref) = universe_guard.as_deref() {
// Build a per-query FilterEvaluator from whatever indexes are
// available. When an index is `None` (no items have been written
// to it yet), the empty fallback causes filters on that field to
// match nothing -- which is the correct semantic: if no items
// have a "category" field indexed, then `CategoryEq("jazz")`
// should return zero results.
let empty_bitmap = BitmapIndex::new("_empty");
let empty_dur = RangeIndex::<u32>::new("_empty");
let empty_ts = RangeIndex::<u64>::new("_empty");
let cat = self.category_index.unwrap_or_else(|| {
tracing::debug!(
"category_index unavailable; CategoryEq/In filters will return no matches"
);
&empty_bitmap
});
let fmt = self.format_index.unwrap_or_else(|| {
tracing::debug!(
"format_index unavailable; FormatEq/In filters will return no matches"
);
&empty_bitmap
});
let cre = self.creator_index.unwrap_or(&empty_bitmap);
let tag = self.tag_index.unwrap_or(&empty_bitmap);
let dur = self.duration_index.unwrap_or(&empty_dur);
let ts = self.created_at_index.unwrap_or(&empty_ts);
let evaluator = FilterEvaluator::new(cat, fmt, cre, tag, dur, ts, universe_ref);
match evaluator.evaluate(filter_expr) {
FilterResult::Bitmap(bitmap) => {
// INCLUSION test against the match bitmap. The match bitmap
// holds only 32-bit ids, so a candidate id that overflows
// `u32` can never be a legitimate member and must be DROPPED
// — a raw `id as u32` truncation would alias `2^32 + k` onto
// the low slot `k`, which may be present in the bitmap, and
// wrongly RETAIN an item that does not satisfy the filter.
// `SignalRanked` candidates come straight from the ledger,
// which keys on full-u64 ids, so a `> u32::MAX` id is valid
// input that reaches here. Route through the checked
// `entity_as_u32` (drop-on-overflow), matching the
// InCollection/SocialGraph arms and the Stage 2.5 helper.
candidates.retain(|id| {
super::entity_as_u32(*id).is_some_and(|i| bitmap.contains(i))
});
}
FilterResult::Predicate(pred) => {
candidates.retain(|id| pred(id.as_u64()));
}
}
}
}
tracing::trace!(candidates = candidates.len(), "stage 2: filter applied");
// ── Stage 2.2/2.3/2.4: Deferred Post-Filters ────────────────────
// InCollection (M6p4), MinSignal/MaxSignal thresholds (M6p3), and
// NearLocation geo (M6p3). These evaluate to the full universe at the
// FilterEvaluator level and must be applied here against the live
// collection index, ledger, and item metadata. Shared verbatim with the
// SEARCH pipeline via `post_filter::apply_deferred_post_filters` so the
// two surfaces cannot drift (a missing arm there silently leaked items
// that violated the filter).
if let Some(ref filter_expr) = combined_filter {
let ctx = post_filter::PostFilterCtx {
ledger: self.ledger,
collection_index: self.collection_index,
items_storage: self.items_storage,
degradation_level: self.degradation_level,
};
post_filter::apply_deferred_post_filters(filter_expr, &mut candidates, &ctx)?;
}
// ── Stage 2.5: User-Context Filtering (M3) ─────────────────────
// When FOR USER is specified, exclude seen/hidden items, items from
// blocked creators, and hard negatives, then apply user-state inclusion
// filters. The whole block is shared verbatim with the SEARCH pipeline
// via `super::apply_user_context_suppression` so the two surfaces cannot
// drift (a missing arm on one side silently leaks an item the other
// suppresses).
if let Some(user_id) = query.for_user {
super::apply_user_context_suppression(
&mut candidates,
user_id,
self.user_state,
self.creator_items,
self.hard_negatives,
combined_filter.as_ref(),
);
tracing::trace!(
candidates = candidates.len(),
user_id = user_id,
"stage 2.5: user-context filter applied"
);
}
stats.candidates_after_filter = candidates.len();
stats.filters_applied = query.filters.len();
// ── DateSaved validation (M6p3) ─────────────────────────────────
// DateSaved sort requires FOR USER context. Fail early with a clear
// error rather than silently returning bad ordering.
if matches!(profile.sort, Some(Sort::DateSaved)) && query.for_user.is_none() {
return Err(QueryError::InvalidFilter {
field: "sort".to_string(),
reason: "DateSaved sort requires FOR USER context".to_string(),
});
}
// ── Stage 3: Signal Scoring ─────────────────────────────────────
let scoring_start = Instant::now();
let now = Timestamp::now();
// `item_metadata` is the per-candidate metadata map Stage 3 pre-loaded
// once from `items_storage`. It is retained (not discarded) so Stage 4.5
// can resolve `creator_id` from it instead of re-reading storage per
// candidate inside `apply_notification_caps` (see the re-population below).
let (scored, item_metadata) = self.stage3_score(
&candidates,
profile,
query,
combined_filter.as_ref(),
now,
&mut stats,
&mut warnings,
)?;
let total_scored = scored.len();
stats.scoring_time_us = scoring_start.elapsed().as_micros() as u64;
tracing::trace!(scored = scored.len(), "stage 3: scored");
// ── Stage 4: Diversity Enforcement ──────────────────────────────
// Use explicit query diversity if set; otherwise fall back to the profile's
// DiversitySpec so that built-in profiles (e.g. notification: max_per_creator=1)
// are always enforced even when the caller does not set query.diversity.
let profile_diversity: Option<crate::ranking::diversity::DiversityConstraints> = {
let spec = &profile.diversity;
if spec.max_per_creator.is_some() || spec.format_mix_max_fraction.is_some() {
Some(crate::ranking::diversity::DiversityConstraints {
max_per_creator: spec.max_per_creator,
format_mix_max_fraction: spec.format_mix_max_fraction,
..crate::ranking::diversity::DiversityConstraints::default()
})
} else {
None
}
};
let effective_diversity = query.diversity.as_ref().or(profile_diversity.as_ref());
// M7p2: NoDiversity — skip diversity enforcement entirely under load.
let diversity_start = Instant::now();
let (final_candidates, constraints_satisfied) = if self.degradation_level.skips_diversity()
{
warnings.push("diversity enforcement skipped due to load degradation".to_string());
(scored, true)
} else if let Some(diversity) = effective_diversity {
// Pass the full scored set (not query.limit) so diversity constraints are
// applied before pagination slicing. This ensures the page boundary does
// not artificially inflate or reduce the diversity-enforced count.
let result = DiversitySelector::select(&scored, diversity, scored.len());
let satisfied = result.constraints_satisfied;
if !satisfied {
for v in &result.violations {
warnings.push(format!(
"diversity constraint '{}' relaxed: {}",
v.constraint, v.detail
));
}
}
(result.selected, satisfied)
} else {
(scored, true)
};
stats.diversity_time_us = diversity_start.elapsed().as_micros() as u64;
stats.candidates_after_diversity = final_candidates.len();
tracing::trace!(
final_count = final_candidates.len(),
"stage 4: diversity enforced"
);
// ── Stage 4.5: Notification Cap FILTER (M6p6) ───────────────────
// Trims the post-diversity set against the daily caps WITHOUT recording.
// Recording is deferred to Stage 5 so it counts only the delivered page,
// not items pagination drops (C-CRITICAL).
let final_candidates =
if let (Some(caps), Some(user_id)) = (query.notification_caps, query.for_user) {
let mut final_candidates = final_candidates;
// Backfill `creator_id` from the metadata Stage 3 already loaded so
// the per-creator cap check never re-reads storage per candidate.
// Stage 3 populates `creator_id` on the *scored* set, but only when
// the field was `None`; backfilling again here is cheap (a HashMap
// lookup) and closes the case where diversity selection or a later
// pass left a survivor with an unresolved creator. Without this, the
// `resolve_candidate_creator_id` fallback issues a storage `get()`
// for every still-unresolved candidate. Shares the
// one `backfill_creator_ids` helper with Stage 3 so the two passes
// cannot drift on the parse rule.
super::backfill_creator_ids(&mut final_candidates, &item_metadata);
self.apply_notification_caps(final_candidates, caps, user_id, &query.profile.name)
} else {
final_candidates
};
tracing::trace!(
final_count = final_candidates.len(),
"stage 4.5: notification cap filter applied (recording deferred to stage 5)"
);
// ── Stage 5: Result Assembly ────────────────────────────────────
// `Cursor::offset` is fallible: a keyset cursor used as an offset (or a
// 32-bit-overflowing value) is a loud `InvalidCursor` rather than a silent
// misread.
let offset = query.cursor.as_ref().map_or(Ok(0), Cursor::offset)?;
// Guard against cursor-offset overflow: an attacker-supplied cursor can
// carry an `offset` near `usize::MAX`, and `offset + limit` would wrap.
// `saturating_add` clamps to `usize::MAX`; the `.min(len)` then keeps the
// slice bounds valid and the empty-page branch below handles `offset >= len`.
let end = offset
.saturating_add(query.limit)
.min(final_candidates.len());
let page_slice = if offset < final_candidates.len() {
&final_candidates[offset..end]
} else {
&[]
};
// ── Notification delivery recording (post-pagination) ───────────────
// Record deliveries against ONLY the page actually returned to the caller,
// not the full post-diversity set. Stage 4.5 filtered without recording;
// this records the delivered page through the tracker's atomic
// check-and-record so the daily cap is consumed by exactly the
// notifications the caller receives — never items trimmed by pagination
// (C-CRITICAL: notification-cap recording must follow pagination). For
// non-notification profiles or no tracker this returns the page unchanged.
let recorded_page: Vec<crate::ranking::executor::ScoredCandidate> =
if let (Some(caps), Some(user_id)) = (query.notification_caps, query.for_user) {
self.record_notification_deliveries(page_slice, caps, user_id, &query.profile.name)
} else {
page_slice.to_vec()
};
let page: &[crate::ranking::executor::ScoredCandidate] = &recorded_page;
let items: Vec<RetrieveResult> = page
.iter()
.enumerate()
.map(|(i, c)| {
let signals: Vec<Signal> = c
.signal_snapshot
.iter()
.map(|(name, value)| Signal {
name: name.as_str().to_owned(),
value: *value,
source: "decay_score".to_string(),
})
.collect();
let reasons = crate::ranking::reason::select_top_reasons(c.reasons.clone());
RetrieveResult {
entity_id: c.entity_id,
score: c.score,
rank: offset + i + 1,
signals,
reasons,
}
})
.collect();
// M2: offset-based pagination. The cursor encodes a list position rather
// than a (score, entity_id) keyset. This is stable only when the ranked
// list does not change between page requests (no concurrent signal writes
// during iteration). Replace with keyset pagination in M3.
let next_cursor = if end < final_candidates.len() {
Some(Cursor::from_offset(end))
} else {
None
};
tracing::trace!(
returned = items.len(),
has_next_cursor = next_cursor.is_some(),
"stage 5: results assembled"
);
stats.total_time_us = query_start.elapsed().as_micros() as u64;
Ok(Results {
items,
next_cursor,
total_candidates: total_scored,
constraints_satisfied,
warnings,
session_snapshot: self.session_snapshot.clone(),
degradation_level: self.degradation_level,
stats,
// Local queries carry no governance metadata; community-overlay
// queries (M9p2+) populate this.
policy_metadata: crate::query::retrieve::types::PolicyMetadata::default(),
})
}
}
// ── Tests ────────────────────────────────────────────────────────────────────
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::time::Duration;
// `Retrieve`, `CandidateStrategy`, `EntityId`, and `Timestamp` are in scope
// via `use super::*`; `FilterExpr` is test-only here (the production pipeline
// body no longer names it directly), so it is imported explicitly to keep the
// module-level imports free of an otherwise-unused name.
use super::*;
use crate::{
ranking::{
profile::{DiversitySpec, RankingProfile},
registry::ProfileRegistry,
},
schema::{DecaySpec, EntityKind, SchemaBuilder, Window as RankWindow},
signals::{NoopWalWriter, SignalLedger},
storage::indexes::FilterExpr,
};
/// Schema with a single `view` signal so a `min_signal("view", …)` deferred
/// filter resolves and the `SignalRanked` strategy can generate candidates.
fn view_schema() -> crate::schema::Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
"view",
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(7 * 24 * 3600),
},
)
.windows(&[RankWindow::AllTime])
.add();
builder.build().unwrap()
}
/// Registry holding one `SignalRanked` profile. Unlike the built-in `Scan`
/// profiles, `SignalRanked` sources candidates from the ledger, NOT the
/// universe bitmap — exactly what the W28 "no universe wired" path needs so
/// candidate generation does not vanish before the deferred post-filter runs.
fn signal_ranked_registry() -> ProfileRegistry {
let mut reg = ProfileRegistry::new();
reg.register(RankingProfile {
name: "view_ranked".to_owned(),
version: 1,
candidate_strategy: CandidateStrategy::SignalRanked {
signal: "view".to_owned(),
window: RankWindow::AllTime,
},
boosts: vec![],
decay: None,
gates: vec![],
penalties: vec![],
excludes: vec![],
diversity: DiversitySpec::default(),
exploration: 0.0,
sort: None,
is_builtin: false,
})
.unwrap();
reg
}
/// W28 regression: a deferred `MinSignal` filter with NO universe wired must
/// still return candidates from `SignalRanked` retrieval and let the Stage
/// 2.3 post-filter trim them — NOT collapse to an empty set.
///
/// Before the fix, RETRIEVE Stage 2 fell back to an EMPTY `empty_universe`
/// when `self.universe` was `None`/poisoned, ran the `FilterEvaluator`
/// (which resolves `MinSignal` to that empty universe), and `candidates.retain`
/// dropped EVERY candidate before the post-filter could run. SEARCH already
/// skipped the index intersection in that case; this mirrors the same fix for
/// RETRIEVE.
#[test]
fn retrieve_min_signal_with_no_universe_still_postfilters() {
let schema = view_schema();
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
let profile_reg = signal_ranked_registry();
// Item 1 has 5 views (>= 3), item 2 has 1 view (< 3). Both have a `view`
// signal so both become SignalRanked candidates.
let now = Timestamp::now();
for _ in 0..5 {
ledger
.record_signal("view", EntityId::new(1), 1.0, now)
.unwrap();
}
ledger
.record_signal("view", EntityId::new(2), 1.0, now)
.unwrap();
// NOTE: `universe = None` — the exact condition that previously dropped
// every candidate in Stage 2.
let exec = RetrieveExecutor::new(
&ledger,
&profile_reg,
None, // category
None, // format
None, // creator
None, // tag
None, // duration
None, // created_at
None, // universe
);
let query = Retrieve::builder()
.profile("view_ranked")
.filter(FilterExpr::min_signal("view", 3.0))
.limit(10)
.build()
.unwrap();
let results = exec.execute(&query).unwrap();
let mut ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
ids.sort_unstable();
assert_eq!(
ids,
vec![1],
"with no universe wired, min_signal(view,3) must keep item 1 and drop item 2 via the \
post-filter; before the fix the empty-universe intersection dropped BOTH"
);
}
/// Regression: the user-context suppression stage shared by RETRIEVE and
/// SEARCH (`super::apply_user_context_suppression`) must (a) exclude seen
/// items, (b) retain only saved items when a `Saved` filter is present, and
/// (c) treat a `> u32::MAX` id correctly under the u32 conversion — RETAIN it
/// against an exclusion bitmap (it can never be a member, and the old raw
/// `id as u32` truncation would have aliased it onto a low suppressed id and
/// dropped the wrong item).
#[test]
fn shared_suppression_excludes_seen_and_keeps_overflow_ids() {
use crate::entities::{HardNegIndex, UserStateIndex};
let user_state = UserStateIndex::new();
// User 42 has seen item 1.
user_state.mark_seen(42, 1);
let hard_neg = HardNegIndex::new();
// Candidate 1 is seen (must drop). Candidate 2 is unseen (must keep).
// Candidate `u64::from(u32::MAX) + 1` overflows u32: it can never be the
// seen bitmap's member, so it MUST be retained — a raw truncation would
// alias it onto slot 0 (== item 1, which IS seen) and wrongly drop it.
let overflow_id = u64::from(u32::MAX) + 1;
let mut candidates = vec![
EntityId::new(1),
EntityId::new(2),
EntityId::new(overflow_id),
];
crate::query::executor::apply_user_context_suppression(
&mut candidates,
42,
Some(&user_state),
None,
Some(&hard_neg),
None,
);
let mut ids: Vec<u64> = candidates.iter().map(|id| id.as_u64()).collect();
ids.sort_unstable();
assert_eq!(
ids,
vec![2, overflow_id],
"seen item 1 dropped; unseen item 2 and the overflow id (un-aliasable, so retained) kept"
);
}
/// Regression: a `Saved` inclusion filter routed through the shared
/// suppression helper keeps only saved items, and an overflow id is DROPPED
/// against the inclusion bitmap (it cannot satisfy membership).
#[test]
fn shared_suppression_saved_inclusion_drops_unsaved_and_overflow() {
use crate::entities::UserStateIndex;
let user_state = UserStateIndex::new();
user_state.add_save(42, 2); // only item 2 is saved
let overflow_id = u64::from(u32::MAX) + 1;
let mut candidates = vec![
EntityId::new(1),
EntityId::new(2),
EntityId::new(overflow_id),
];
crate::query::executor::apply_user_context_suppression(
&mut candidates,
42,
Some(&user_state),
None,
None,
Some(&FilterExpr::Saved(42)),
);
assert_eq!(
candidates,
vec![EntityId::new(2)],
"Saved(42) keeps only item 2; item 1 (unsaved) and the overflow id (cannot be an \
inclusion-bitmap member) are both dropped"
);
}
/// C-CRITICAL regression (Stage 2 inclusion intersection): a `SignalRanked`
/// candidate whose id is `(1<<32) + k` must be DROPPED by an index-backed
/// `CategoryEq` filter even when slot `k` IS present in the match bitmap. A raw
/// `id as u32` truncation would alias the overflow id onto slot `k` and wrongly
/// RETAIN it; the checked `entity_as_u32` (drop-on-overflow) prevents the leak.
#[test]
fn stage2_index_filter_drops_overflow_alias_candidate() {
use roaring::RoaringBitmap;
use crate::storage::indexes::bitmap::BitmapIndex;
let schema = view_schema();
let ledger = SignalLedger::new(schema, Box::new(NoopWalWriter));
let profile_reg = signal_ranked_registry();
// Real low id `5` is in category "jazz"; the overflow id `(1<<32)+5`
// aliases onto slot 5 under truncation but must NOT be retained.
let k: u32 = 5;
let overflow_id = (1u64 << 32) + u64::from(k);
let now = Timestamp::now();
ledger
.record_signal("view", EntityId::new(u64::from(k)), 1.0, now)
.unwrap();
ledger
.record_signal("view", EntityId::new(overflow_id), 1.0, now)
.unwrap();
// Category index: slot 5 ∈ "jazz". The overflow item is NOT indexed under
// jazz; only its truncated alias (slot 5) would falsely match.
let category = BitmapIndex::new("category");
category.insert(k, "jazz");
// Universe must be Some so Stage 2 runs the index intersection. It only
// gates entry to the block; CategoryEq resolves to the category bitmap.
let mut universe_bm = RoaringBitmap::new();
universe_bm.insert(k);
let universe = std::sync::RwLock::new(universe_bm);
let exec = RetrieveExecutor::new(
&ledger,
&profile_reg,
Some(&category), // category
None, // format
None, // creator
None, // tag
None, // duration
None, // created_at
Some(&universe), // universe
);
let query = Retrieve::builder()
.profile("view_ranked")
.filter(FilterExpr::CategoryEq("jazz".into()))
.limit(10)
.build()
.unwrap();
let results = exec.execute(&query).unwrap();
let ids: Vec<u64> = results.items.iter().map(|r| r.entity_id.as_u64()).collect();
assert_eq!(
ids,
vec![u64::from(k)],
"only the genuine low id 5 may survive CategoryEq(jazz); the overflow id \
{overflow_id} must be dropped, not aliased onto slot {k} and retained"
);
}
}