# Task 02: Query Executor Degradation Branches ## Delivers Wire `DegradationLevel` into `RetrieveExecutor` and `SearchExecutor` so that each stage of the pipeline respects the current degradation level. Candidate generation, signal aggregation, and diversity enforcement all have degradation-aware code paths. ## Complexity: M ## Dependencies - task-01 (`DegradationLevel`, `LoadDetector`, `InFlightGuard`) - m2p5 `RetrieveExecutor` (6-stage pipeline) - m5p3 `SearchExecutor` (8-stage pipeline) ## Technical Design ### 1. Thread DegradationLevel through executor construction Both executors are constructed per-query in `TidalDb::retrieve()` and `TidalDb::search()`. The degradation level is passed as a field on the executor. ```rust // In tidal/src/query/executor/mod.rs, add to RetrieveExecutor: pub struct RetrieveExecutor<'a> { // ... existing fields ... degradation_level: crate::load::DegradationLevel, } impl<'a> RetrieveExecutor<'a> { // Add builder method: #[must_use] pub const fn with_degradation_level( mut self, level: crate::load::DegradationLevel, ) -> Self { self.degradation_level = level; self } } ``` ```rust // In tidal/src/query/search/executor.rs, add to SearchExecutor: pub struct SearchExecutor<'a> { // ... existing fields ... degradation_level: crate::load::DegradationLevel, } impl<'a> SearchExecutor<'a> { #[must_use] pub const fn with_degradation_level( mut self, level: crate::load::DegradationLevel, ) -> Self { self.degradation_level = level; self } } ``` Default to `DegradationLevel::Full` in `new()` so that all existing tests pass without modification. ### 2. Wire LoadDetector into TidalDb::retrieve() and TidalDb::search() In `tidal/src/db/query_ops.rs` (or wherever `retrieve()` / `search()` are implemented): ```rust impl TidalDb { pub fn retrieve(&self, query: &Retrieve) -> crate::Result { // Enter the load detector. The guard decrements on drop (method return). let (degradation_level, _guard) = self.load_detector.enter(); tracing::debug!( degradation = %degradation_level, in_flight = self.load_detector.in_flight(), "query entry" ); // Build executor as before, then wire degradation level: let executor = RetrieveExecutor::new(/* ... */) .with_degradation_level(degradation_level); // ... rest of the method } } ``` Same pattern for `search()`. ### 3. Stage 1 -- ReducedCandidates: ANN top_k and BM25 limit In `SearchExecutor::execute()`, the ANN `top_k` is currently computed as: ```rust let k = (query.limit as usize * 20).max(200); ``` Under `ReducedCandidates`, reduce the over-fetch factor: ```rust let k = if self.degradation_level.reduces_candidates() { // Reduced: cap at 200 regardless of query limit. // This cuts ANN search work by ~60% for typical limit=20 queries. (query.limit as usize * 10).max(100).min(200) } else { (query.limit as usize * 20).max(200) }; ``` For BM25, the `AllScoresCollector` currently returns all matches. Under degradation, post-truncate the BM25 results: ```rust if self.degradation_level.reduces_candidates() { // Cap BM25 candidates at half the normal budget to reduce // downstream scoring work. Truncation preserves BM25 rank order // so the top results are not lost, only the long tail. let bm25_cap = (query.limit as usize * 10).max(100); bm25_results.truncate(bm25_cap); } ``` For `RetrieveExecutor`, the Scan candidate strategy currently caps at the universe size. Under degradation, reduce the scan cap: ```rust // In candidate_gen::scan_candidates, or at the call site: let scan_cap = if degradation_level.reduces_candidates() { query.limit * 5 // reduced from default 10x over-fetch } else { query.limit * 10 }; ``` ### 4. Stage 3 -- CoarseAggregates: signal read fallback The signal scoring stage reads windowed counts and velocity values through `ProfileExecutor::score()` -> `helpers::read_agg()`. Under `CoarseAggregates`, the executor should override the window argument. Add a degradation-aware helper that substitutes coarse windows: ```rust // In tidal/src/ranking/executor/helpers.rs or a new helper module: use crate::load::DegradationLevel; use crate::schema::Window; /// Adjust the window for signal reads under degradation. /// /// Under `CoarseAggregates` or `NoDiversity`: /// - Windowed count requests fall back to `AllTime` (cheapest read) /// - Velocity requests fall back to `TwentyFourHours` (widest cached window) /// /// This avoids per-bucket scans in SWAG and uses pre-aggregated values. #[must_use] pub const fn degraded_window( window: Window, degradation: DegradationLevel, ) -> Window { if degradation.coarsens_aggregates() { Window::AllTime } else { window } } /// Adjust the velocity window under degradation. #[must_use] pub const fn degraded_velocity_window( window: Window, degradation: DegradationLevel, ) -> Window { if degradation.coarsens_aggregates() { Window::TwentyFourHours } else { window } } ``` Wire this into `ProfileExecutor` by threading the `DegradationLevel` through the scoring path. The cleanest approach: add an optional `DegradationLevel` field to `ProfileExecutor`: ```rust impl<'a> ProfileExecutor<'a> { #[must_use] pub const fn with_degradation(mut self, level: DegradationLevel) -> Self { self.degradation_level = level; self } } ``` Then in the `read_agg` call sites inside `score_candidate()`, apply the degradation window substitution. ### 5. Stage 4 -- NoDiversity: skip diversity enforcement In `RetrieveExecutor::execute()`, the diversity stage currently runs unconditionally when constraints are present. Under `NoDiversity`, skip it: ```rust // Stage 4: Diversity Enforcement let (final_candidates, constraints_satisfied) = if self.degradation_level.skips_diversity() { // NoDiversity: skip the diversity pass entirely. // Log a warning so the caller knows quality is reduced. warnings.push( "diversity enforcement skipped due to load degradation".to_string(), ); (scored, true) } else if let Some(diversity) = effective_diversity { let result = DiversitySelector::select(&scored, diversity, scored.len()); // ... existing diversity logic ... } else { (scored, true) }; ``` Same pattern in `SearchExecutor::execute()`: ```rust 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(ref diversity) = query.diversity { // ... existing diversity logic ... } else { (scored, true) }; ``` ### 6. Thread degradation level into Results The executor already constructs `Results` at the end of `execute()`. Pass the degradation level through to the response. This is covered in detail by task-03 (which adds the field to `Results` and `SearchResults`), but the executor must set it: ```rust Ok(Results { items, next_cursor, total_candidates: total_scored, constraints_satisfied, warnings, session_snapshot: self.session_snapshot.clone(), degradation_level: self.degradation_level, }) ``` ## Acceptance Criteria - [ ] `RetrieveExecutor` has `degradation_level` field, default `Full` - [ ] `SearchExecutor` has `degradation_level` field, default `Full` - [ ] `with_degradation_level()` builder method on both executors - [ ] ANN `top_k` reduced under `ReducedCandidates` (capped at 200) - [ ] BM25 results truncated under `ReducedCandidates` (halved cap) - [ ] Scan candidate over-fetch reduced under `ReducedCandidates` - [ ] Signal windowed reads fall back to AllTime under `CoarseAggregates` - [ ] Velocity reads fall back to 24h under `CoarseAggregates` - [ ] Diversity pass skipped under `NoDiversity` with warning - [ ] `LoadDetector::enter()` called in `TidalDb::retrieve()` and `TidalDb::search()` - [ ] `InFlightGuard` held for the duration of each query method - [ ] All existing executor tests still pass (degradation defaults to `Full`) - [ ] `cargo clippy -D warnings` clean ## Test Strategy ```rust #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; use crate::load::DegradationLevel; // Test that executor with DegradationLevel::Full behaves identically // to the pre-degradation baseline (all existing tests implicitly cover this). #[test] fn reduced_candidates_caps_ann_top_k() { // Build a SearchExecutor with ReducedCandidates level. // Verify the ANN search k parameter is <= 200. // This requires either inspecting the k value via a test hook // or verifying that fewer candidates are produced. } #[test] fn no_diversity_skips_enforcement() { // Build 10 items all from the same creator. // Query with max_per_creator=2 and NoDiversity. // All 10 should be returned (diversity not enforced). // Verify warning message includes "load degradation". } #[test] fn no_diversity_adds_warning() { // Run a query under NoDiversity. // Assert results.warnings contains the degradation message. } #[test] fn full_level_enforces_diversity() { // Same setup as no_diversity_skips_enforcement but with Full level. // Only 2 per creator should be returned. } // Property test: for any DegradationLevel, a valid query always // returns Ok (not Err). This verifies "under 3x overload, all // well-formed queries return results." mod proptests { use super::*; use proptest::prelude::*; proptest! { #[test] fn query_always_succeeds_regardless_of_degradation( level in prop::sample::select(vec![ DegradationLevel::Full, DegradationLevel::ReducedCandidates, DegradationLevel::CoarseAggregates, DegradationLevel::NoDiversity, ]) ) { // Build a minimal executor with the given degradation level. // Execute a simple query. Assert Ok. } } } } ```