tidaldb/tidal/src/query/executor/pipeline.rs
jx12n 9728194f16 fix: M0-M10 code-review pass2 remediation — all 91 findings
Resolves every finding in docs/reviews/M0-M10-code-review-2026-06-08-pass2.md
across the engine, network, server, and CLI crates: session restore,
replication/CRDT, WAL format and recovery, storage indexes, query/ranking
executors, cohort/community governance, and scatter-gather routing.

Adds regression tests:
- review_pass2_creator_search_filter
- review_pass2_d_replication
- review_pass2_query_for_session
- review_pass2_storage_indexes_bitmap_cache
- review_pass2_zone_a_sessions

Verified: cargo clippy -D warnings and full test suite green across all crates.
2026-06-09 12:21:00 -06:00

831 lines
38 KiB
Rust

//! 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,
},
};
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, .. } => {
candidate_gen::signal_ranked_candidates(self.ledger, signal, query.limit)
}
CandidateStrategy::Ann { .. } => {
// ANN candidate strategy is not yet wired into RETRIEVE
// (vector retrieval lives in the SEARCH pipeline). The
// fallback to a full scan changes the candidate set the
// profile asked for, so it is surfaced BOTH in the per-query
// `warnings` (caller-visible) AND a `tracing::warn!`
// (operator-visible) — a silent degrade with no log line
// leaves no way to track how often profiles hit this path.
warnings.push(
"ANN candidate strategy not yet wired; falling back to scan".to_string(),
);
tracing::warn!(
profile = %query.profile.name,
"ANN candidate strategy requested but not wired in RETRIEVE; \
falling back to a full scan"
);
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.clone(),
value: *value,
source: "decay_score".to_string(),
})
.collect();
RetrieveResult {
entity_id: c.entity_id,
score: c.score,
rank: offset + i + 1,
signals,
}
})
.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"
);
}
}