# Task 01: QueryStats Struct + Executor Instrumentation ## Delivers `QueryStats` struct capturing per-query execution statistics. Instrumentation of both `RetrieveExecutor` and `SearchExecutor` to populate stats at each pipeline stage. `Results.stats` and `SearchResults.stats` fields added so every query response carries its execution telemetry. ## Complexity: M ## Dependencies - None from prior m7p4 tasks (this is the foundation) - `tidal/src/query/executor/mod.rs` -- `RetrieveExecutor` 6-stage pipeline - `tidal/src/query/search/executor.rs` -- `SearchExecutor` 8-stage pipeline - `tidal/src/query/retrieve/types.rs` -- `Results` struct - `tidal/src/query/search/types.rs` -- `SearchResults` struct ## Technical Design ### 1. QueryStats struct Create `tidal/src/query/stats.rs`: ```rust /// Per-query execution statistics. /// /// Always populated by the executor -- never `None`. Even queries that /// return zero results carry stats reflecting the work done to determine /// that. All timing fields are in microseconds (u64). /// /// Designed as a pure data struct: no methods, no builders, no heap /// allocations. The executor constructs it incrementally using /// `Instant::elapsed()` at each stage boundary. #[derive(Debug, Clone)] pub struct QueryStats { /// Total candidates considered before any filtering. pub candidates_considered: usize, /// Candidates remaining after filter evaluation (Stage 2). pub candidates_after_filter: usize, /// Candidates remaining after diversity enforcement (Stage 4). pub candidates_after_diversity: usize, /// Number of filter expressions evaluated. pub filters_applied: usize, /// Time spent in signal scoring (Stage 3), in microseconds. pub scoring_time_us: u64, /// Time spent in diversity enforcement (Stage 4), in microseconds. pub diversity_time_us: u64, /// Total query execution time from executor entry to result assembly, /// in microseconds. pub total_time_us: u64, /// Current degradation level at query time (0 = healthy, 3 = critical). /// Mirrors the `DegradationLevel` from m7p2. pub degradation_level: u8, /// Name of the ranking profile used. pub profile_name: String, } impl QueryStats { /// Create a stats struct with all zeroed counters and the given profile name. /// /// The executor fills in the fields as it progresses through stages. #[must_use] pub fn new(profile_name: String) -> Self { Self { candidates_considered: 0, candidates_after_filter: 0, candidates_after_diversity: 0, filters_applied: 0, scoring_time_us: 0, diversity_time_us: 0, total_time_us: 0, degradation_level: 0, profile_name, } } } ``` ### 2. Wire QueryStats into Results and SearchResults In `tidal/src/query/retrieve/types.rs`, add to `Results`: ```rust use crate::query::stats::QueryStats; pub struct Results { // ... existing fields ... /// Per-query execution statistics. pub stats: QueryStats, } ``` In `tidal/src/query/search/types.rs`, add to `SearchResults`: ```rust use crate::query::stats::QueryStats; pub struct SearchResults { // ... existing fields ... /// Per-query execution statistics. pub stats: QueryStats, } ``` ### 3. Re-export from query module In `tidal/src/query/mod.rs`, add: ```rust pub mod stats; pub use stats::QueryStats; ``` ### 4. Instrument RetrieveExecutor In `tidal/src/query/executor/mod.rs`, wrap the `execute()` method's stages with timing: ```rust use std::time::Instant; use crate::query::stats::QueryStats; // Inside execute(): let query_start = Instant::now(); let mut stats = QueryStats::new(query.profile.name.clone()); // After Stage 1 (candidate generation): stats.candidates_considered = candidates.len(); // After Stage 2 (filter evaluation): stats.candidates_after_filter = filtered.len(); stats.filters_applied = query.filters.len(); // Stage 3 (scoring): let scoring_start = Instant::now(); // ... existing scoring logic ... stats.scoring_time_us = scoring_start.elapsed().as_micros() as u64; // Stage 4 (diversity): let diversity_start = Instant::now(); // ... existing diversity logic ... stats.diversity_time_us = diversity_start.elapsed().as_micros() as u64; stats.candidates_after_diversity = diversified.len(); // Final assembly: stats.total_time_us = query_start.elapsed().as_micros() as u64; ``` ### 5. Instrument SearchExecutor Same pattern in `tidal/src/query/search/executor.rs`: ```rust // Inside execute(): let query_start = Instant::now(); let mut stats = QueryStats::new(query.profile.name.clone()); // After Stage 1c (fusion): stats.candidates_considered = fused.len(); // After Stage 2 (metadata + user filter): stats.candidates_after_filter = filtered.len(); stats.filters_applied = query.filters.len(); // Stage 3 (profile scoring): let scoring_start = Instant::now(); // ... existing scoring logic ... stats.scoring_time_us = scoring_start.elapsed().as_micros() as u64; // Stage 4 (diversity): let diversity_start = Instant::now(); // ... existing diversity logic ... stats.diversity_time_us = diversity_start.elapsed().as_micros() as u64; stats.candidates_after_diversity = diversified.len(); // Final assembly: stats.total_time_us = query_start.elapsed().as_micros() as u64; ``` ### 6. Update all Results construction sites Every place that constructs `Results` or `SearchResults` must now include the `stats` field. Search the codebase for `Results {` in retrieve executor code and `SearchResults {` in search executor code. Each site gets the stats struct built during that execution. For test code that constructs `Results` or `SearchResults` directly, use `QueryStats::new("test".to_owned())` as a sensible default. ## Acceptance Criteria - [ ] `QueryStats` struct defined in `tidal/src/query/stats.rs` with all 9 fields - [ ] `Results.stats: QueryStats` field added - [ ] `SearchResults.stats: QueryStats` field added - [ ] `RetrieveExecutor::execute()` populates all `QueryStats` fields with correct values - [ ] `SearchExecutor::execute()` populates all `QueryStats` fields with correct values - [ ] `total_time_us >= scoring_time_us + diversity_time_us` (invariant) - [ ] `candidates_considered >= candidates_after_filter >= candidates_after_diversity` (invariant) - [ ] `filters_applied` matches the number of filter expressions in the query - [ ] `profile_name` matches the profile used for scoring - [ ] All existing tests updated to include `stats` field in constructed results - [ ] `cargo clippy -D warnings` and `cargo fmt --check` pass ## Test Strategy ```rust #[cfg(test)] mod tests { use super::*; #[test] fn query_stats_new_zeroed() { let stats = QueryStats::new("trending".to_owned()); assert_eq!(stats.candidates_considered, 0); assert_eq!(stats.candidates_after_filter, 0); assert_eq!(stats.candidates_after_diversity, 0); assert_eq!(stats.filters_applied, 0); assert_eq!(stats.scoring_time_us, 0); assert_eq!(stats.diversity_time_us, 0); assert_eq!(stats.total_time_us, 0); assert_eq!(stats.degradation_level, 0); assert_eq!(stats.profile_name, "trending"); } #[test] fn query_stats_timing_invariant() { // After a real query execution, total >= scoring + diversity. // Tested via integration test against a live TidalDb instance. } #[test] fn query_stats_candidate_funnel_invariant() { // After a real query, considered >= after_filter >= after_diversity. // Tested via integration test against a live TidalDb instance. } } ``` Integration test in `tidal/tests/m7p4_visibility.rs`: ```rust #[test] fn retrieve_populates_query_stats() { let db = make_test_db_with_items(100); let query = Retrieve::builder() .profile("trending") .limit(10) .build() .unwrap(); let results = db.retrieve(&query).unwrap(); assert!(results.stats.candidates_considered > 0); assert!(results.stats.candidates_considered >= results.stats.candidates_after_filter); assert!(results.stats.candidates_after_filter >= results.stats.candidates_after_diversity); assert!(results.stats.total_time_us > 0); assert_eq!(results.stats.profile_name, "trending"); } #[test] fn search_populates_query_stats() { let db = make_test_db_with_items_and_text(100); let query = Search::builder().query("test").limit(10).build().unwrap(); let results = db.search(&query).unwrap(); assert!(results.stats.total_time_us > 0); assert_eq!(results.stats.profile_name, "search"); } ```