//! Query execution helpers: fast path, filtering, decay, ranking. //! //! This module handles the post-candidate-fetch logic: //! - Fast path: materialized view lookup and staleness checks //! - Filtering: lifecycle, epoch, as_of, since //! - Decay: time-based confidence decay (uniform or source-class-aware) //! - Limit/pagination use std::time::{SystemTime, UNIX_EPOCH}; use stemedb_core::types::{Assertion, MaterializedView}; use stemedb_storage::{key_codec, KVStore}; use tracing::debug; use crate::decay::{apply_decay, apply_source_class_decay}; use crate::error::Result; use crate::query::{Query, QueryResult}; use super::QueryEngine; impl QueryEngine { /// Try the fast path: read a pre-computed MaterializedView for this subject+predicate. /// /// Returns `Some(QueryResult)` if a materialized view exists, is fresh enough, /// and the winner passes the query's filters. Returns `None` to fall through /// to the slow path. /// /// # Staleness Check /// /// If `query.max_stale` is set, the MV's age is checked against this threshold. /// If the MV is older than `max_stale` seconds, we fall through to the slow path. pub(super) async fn try_fast_path( &self, subject: &str, predicate: &str, query: &Query, ) -> Result> { let mv_key = key_codec::mv_key(subject, predicate); let data = match self.store.get(&mv_key).await? { Some(data) => data, None => return Ok(None), }; let view: MaterializedView = match stemedb_core::serde::deserialize(&data) { Ok(v) => v, Err(e) => { debug!( subject, predicate, error = %e, "Malformed materialized view, falling back to slow path" ); return Ok(None); } }; // Check staleness if max_stale is specified if let Some(max_stale) = query.max_stale { let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); let age = now.saturating_sub(view.materialized_at); if age > max_stale { debug!( subject, predicate, age_seconds = age, max_stale_seconds = max_stale, materialized_at = view.materialized_at, "Materialized view is stale, falling back to slow path" ); return Ok(None); } } // Apply query filters to the winner if !query.matches(&view.winner) { // Winner doesn't match filters (e.g., wrong lifecycle stage). // Fall through to slow path which considers all candidates. return Ok(None); } // Check conflict score filters if specified if let Some(min_score) = query.min_conflict_score { if view.conflict_score < min_score { debug!( conflict_score = view.conflict_score, min_conflict_score = min_score, "Materialized view conflict score below threshold" ); return Ok(Some(QueryResult { assertions: vec![], total_count: 0, has_more: false, })); } } if let Some(max_score) = query.max_conflict_score { if view.conflict_score > max_score { debug!( conflict_score = view.conflict_score, max_conflict_score = max_score, "Materialized view conflict score above threshold" ); return Ok(Some(QueryResult { assertions: vec![], total_count: 0, has_more: false, })); } } Ok(Some(QueryResult { assertions: vec![view.winner], total_count: 1, has_more: false })) } /// Apply filters to candidates and construct the final QueryResult. /// /// This is used by similarity search paths to apply post-filtering /// (subject, predicate, lifecycle, epoch) and handle limit/truncation. pub(super) async fn apply_filters_and_return( &self, candidates: Vec, query: &Query, ) -> Result { debug!(candidate_count = candidates.len(), "Applying filters to candidates"); // Apply filters let mut matching: Vec = candidates.into_iter().filter(|a| query.matches(a)).collect(); // Apply decay if decay_halflife is set if let Some(halflife) = query.decay_halflife { // Use as_of timestamp if set (time-travel), otherwise current time let now = query.as_of.unwrap_or_else(|| { SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) }); if query.source_class_decay { debug!( halflife_seconds = halflife, now, source_class_aware = true, "Applying source-class-aware decay" ); matching = apply_source_class_decay(&matching, halflife, now); } else { debug!(halflife_seconds = halflife, now, "Applying uniform decay"); matching = apply_decay(&matching, halflife, now); } } let total_count = matching.len(); // Apply limit if specified let has_more = if let Some(limit) = query.limit { if matching.len() > limit { matching.truncate(limit); true } else { false } } else { false }; debug!(matched_count = matching.len(), total_count, has_more, "Query complete"); Ok(QueryResult { assertions: matching, total_count, has_more }) } }