//! Search query execution on `Store`.
//!
//! Contains the `impl Store` block with all search methods:
//! `search_filtered`, `finalize_results`, `search_filtered_with_index`,
//! `search_by_candidate_ids`, and `search_code_results`.

use std::collections::{HashMap, HashSet};

use crate::embedder::Embedding;
use crate::index::VectorIndex;
use crate::limits::candidate_count_for;
use crate::nl::normalize_for_fts;
use crate::parser::ChunkType;
use crate::store::helpers::{
    embedding_slice, CandidateRow, ChunkSummary, SearchFilter, SearchResult,
};
use crate::store::sanitize_fts_query;
use crate::store::{NoteSummary, Store, StoreError};

use super::mmr::{mmr_lambda_from_env, mmr_rerank, MmrCandidate};
use super::scoring::{
    apply_parent_boost, apply_scoring_pipeline, build_filter_sql, compile_glob_filter, rrf_fuse,
    score_candidate, signals_for, BoundedScoreHeap, NameMatcher, NoteBoost, RankSignalCtx,
    RankSignalInputs, ScoringContext,
};
use super::synonyms::expand_query_for_fts;

/// One chunk's position in the dense (cosine) retrieval leg of SPLADE fusion.
///
/// `raw_cosine` is the un-normalized cosine the vector index reported. NOTE the
/// honest fusion semantics (see [`SearchLegs`]): the dense leg is NOT scaled
/// before fusion, so this value lives in cosine `[-1, 1]`, not `[0, 1]`.
///
/// `present_in_pool` distinguishes "the dense retriever returned this chunk and
/// it ranked low" (`true`) from "the dense retriever never retrieved this chunk;
/// fusion injected `raw_cosine = 0.0` for it" (`false`). A chunk found only by
/// the sparse leg appears here with `present_in_pool = false`, `raw_cosine =
/// 0.0`, and `rank = 0` — its dense state is *absent*, not *zero-similarity*.
#[derive(Debug, Clone, serde::Serialize)]
pub struct DenseLegEntry {
    /// Chunk ID (matches `Store` chunk IDs and the other legs' `chunk_id`).
    pub chunk_id: String,
    /// Un-normalized cosine in `[-1, 1]` when present; `0.0` (injected) when not.
    pub raw_cosine: f32,
    /// 1-indexed rank within the dense pool when present; `0` when absent
    /// (`rank == 0` iff `present_in_pool == false`).
    pub rank: usize,
    /// Whether the dense retriever actually returned this chunk.
    pub present_in_pool: bool,
}

/// One chunk's position in the sparse (SPLADE) retrieval leg of SPLADE fusion.
///
/// Carries BOTH the value fusion uses and the raw measurement:
/// - `minmax_score` is the query-relative min-max normalization
///   (`raw_splade_dot / max_sparse`) in `[0, 1]` — the value that enters the
///   fusion sum. `max_sparse` is the maximum dot over THIS query's sparse pool,
///   so the same chunk can normalize differently across queries.
/// - `raw_splade_dot` is the un-normalized query·document SPLADE dot product —
///   stable across queries, which token-attribution inspection needs (the
///   min-max value is query-relative and not comparable across runs).
///
/// `present_in_pool` has the same meaning as on [`DenseLegEntry`]: `false`
/// marks a chunk the sparse retriever never returned (both scores injected
/// `0.0`, `rank = 0`).
#[derive(Debug, Clone, serde::Serialize)]
pub struct SparseLegEntry {
    /// Chunk ID (matches `Store` chunk IDs and the other legs' `chunk_id`).
    pub chunk_id: String,
    /// Query-relative min-max normalized score in `[0, 1]` (the fused value).
    pub minmax_score: f32,
    /// Un-normalized SPLADE dot product (stable across queries).
    pub raw_splade_dot: f32,
    /// 1-indexed rank within the sparse pool when present; `0` when absent
    /// (`rank == 0` iff `present_in_pool == false`).
    pub rank: usize,
    /// Whether the sparse retriever actually returned this chunk.
    pub present_in_pool: bool,
}

/// One chunk's position in the fused (final pre-truncation) ranking.
#[derive(Debug, Clone, serde::Serialize)]
pub struct FusedLegEntry {
    /// Chunk ID (matches `Store` chunk IDs and the other legs' `chunk_id`).
    pub chunk_id: String,
    /// The fused score `score = α·dense_raw_cosine + (1−α)·sparse_minmax`.
    pub fused_score: f32,
    /// 1-indexed rank in the fused ranking.
    pub rank: usize,
}

/// The three pre-fusion retrieval legs surfaced for the SPLADE inspector.
///
/// HONEST FUSION SEMANTICS (the inspector must present these, not hide them):
/// the fused score is
/// `α·dense_raw_cosine([-1, 1]) + (1−α)·sparse_minmax([0, 1])`. The two legs
/// are on ASYMMETRIC scales — the dense leg is the raw cosine and is NOT
/// normalized, while the sparse leg is min-max normalized to `[0, 1]` using a
/// `max_sparse` that is computed per-query (so a chunk's `minmax_score` is not
/// comparable across queries; its `raw_splade_dot` is). `α = filter.splade_alpha`.
///
/// `dense` and `sparse` are each built over the UNION of candidate IDs from
/// both legs (sorted by their own leg's rank, present entries first), so a
/// consumer can align all three arrays by `chunk_id` and read, per chunk,
/// whether each leg retrieved it (`present_in_pool`) and at what raw score.
/// `fused` is the post-sort, post-truncate ranking the final result list is
/// built from — `fused[i].chunk_id` is the i-th candidate fusion produced.
#[derive(Debug, Clone, serde::Serialize)]
pub struct SearchLegs {
    /// Dense (cosine) leg over the candidate union.
    pub dense: Vec<DenseLegEntry>,
    /// Sparse (SPLADE) leg over the candidate union.
    pub sparse: Vec<SparseLegEntry>,
    /// Fused leg: the final pre-truncation ranking.
    pub fused: Vec<FusedLegEntry>,
}

/// Build the three [`SearchLegs`] from the in-flight fusion state.
///
/// Called only on the `capture_legs` path of `search_hybrid_inner`, so its
/// allocations never touch the production hot path. Every input is borrowed
/// from data the fusion already computed; this function only reshapes it into
/// the leg structs (no re-retrieval, no re-scoring).
///
/// `dense_results` / `sparse_results` are each already sorted by their leg's
/// score descending (the vector index and `BoundedScoreHeap::into_sorted_vec`
/// respectively), so the enumeration index is the 1-indexed leg rank. `fused`
/// is the sorted-and-truncated fused ranking.
fn build_search_legs(
    dense_results: &[crate::index::IndexResult],
    dense_scores: &std::collections::HashMap<&str, f32>,
    sparse_results: &[crate::index::IndexResult],
    sparse_scores: &std::collections::HashMap<&str, f32>,
    max_sparse: f32,
    all_ids: &[&str],
    fused: &[crate::index::IndexResult],
) -> SearchLegs {
    // Per-leg rank maps from the already-sorted result vecs. Insertion-order
    // dedup (`or_insert`) keeps the first (best-ranked) occurrence, matching
    // the `sparse_ranks` convention used by the rank-signals recorder.
    let mut dense_rank: std::collections::HashMap<&str, usize> =
        std::collections::HashMap::with_capacity(dense_results.len());
    for (i, r) in dense_results.iter().enumerate() {
        dense_rank.entry(r.id.as_str()).or_insert(i + 1);
    }
    // Raw SPLADE dot per id (un-normalized). `sparse_scores` holds the min-max
    // value; the raw dot lives on `sparse_results.score`.
    let mut sparse_rank: std::collections::HashMap<&str, usize> =
        std::collections::HashMap::with_capacity(sparse_results.len());
    let mut sparse_raw: std::collections::HashMap<&str, f32> =
        std::collections::HashMap::with_capacity(sparse_results.len());
    for (i, r) in sparse_results.iter().enumerate() {
        if sparse_rank.insert(r.id.as_str(), i + 1).is_none() {
            sparse_raw.insert(r.id.as_str(), r.score);
        }
    }

    // Dense + sparse legs over the candidate union, in `all_ids` order (dense
    // pool first, then sparse-only — the same deterministic order fusion used).
    let mut dense = Vec::with_capacity(all_ids.len());
    let mut sparse = Vec::with_capacity(all_ids.len());
    for &id in all_ids {
        let dense_present = dense_scores.contains_key(id);
        dense.push(DenseLegEntry {
            chunk_id: id.to_string(),
            // Mirrors the fusion `unwrap_or(0.0)`: absent ⇒ injected zero.
            raw_cosine: dense_scores.get(id).copied().unwrap_or(0.0),
            rank: dense_rank.get(id).copied().unwrap_or(0),
            present_in_pool: dense_present,
        });
        let sparse_present = sparse_scores.contains_key(id);
        sparse.push(SparseLegEntry {
            chunk_id: id.to_string(),
            minmax_score: sparse_scores.get(id).copied().unwrap_or(0.0),
            raw_splade_dot: sparse_raw.get(id).copied().unwrap_or(0.0),
            rank: sparse_rank.get(id).copied().unwrap_or(0),
            present_in_pool: sparse_present,
        });
    }

    // Sort each leg so present entries lead in rank order; absent entries
    // (rank 0) sink to the tail keeping `all_ids` order among themselves. This
    // makes the array directly readable as "the leg's ranking, then the chunks
    // the other leg contributed".
    dense.sort_by_key(|e| if e.rank == 0 { usize::MAX } else { e.rank });
    sparse.sort_by_key(|e| if e.rank == 0 { usize::MAX } else { e.rank });

    let fused_leg = fused
        .iter()
        .enumerate()
        .map(|(i, r)| FusedLegEntry {
            chunk_id: r.id.clone(),
            fused_score: r.score,
            rank: i + 1,
        })
        .collect();

    // `max_sparse` is referenced for documentation parity with the fusion math;
    // the normalized values are already in `sparse_scores`. Keep the param so
    // the signature names the query-relative normalizer the doc comment cites.
    let _ = max_sparse;

    SearchLegs {
        dense,
        sparse,
        fused: fused_leg,
    }
}

/// Resolve the type-boost factor used by `finalize_results` Step 4b.
///
/// Multiplicative boost applied to chunks whose type matches the
/// router-provided type hints. Default + bounds + env var name live on the
/// `type_boost` row in [`crate::search::scoring::knob::SCORING_KNOBS`]; the
/// row's `cache: false` setting is load-bearing — `evals/run_sweep.py` mutates
/// `CQS_TYPE_BOOST` between queries within the same `cqs` process and expects
/// every call to re-read the env.
pub(crate) fn type_boost_factor() -> f32 {
    crate::search::scoring::knob::resolve_knob("type_boost")
}

/// Trim `k` to the backend's reported `max_k` cap.
///
/// CAGRA enforces `itopk_size >= k` and `itopk_size <= itopk_max`, where
/// `itopk_max` is `(log2(n_vectors) * 32).clamp(128, 4096)` — 441 at 14k
/// chunks, 532 at 100k, 640 at 1M. A request with `k > itopk_max`
/// returns an empty Vec; in the SPLADE-fusion path this collapses the
/// dense leg to zero scores, and at `splade_alpha = 1.0` the fused
/// score is `1.0·d + 0.0·s = 0` for everyone — R@5 craters from ~0.66
/// to ~0.16 on the v3.v2 test set when `candidate_count` crosses 441.
/// HNSW reports `max_k = None` and is unaffected.
fn cap_k_to_backend(idx: &dyn VectorIndex, k: usize) -> usize {
    match idx.max_k() {
        Some(cap) if k > cap => {
            tracing::debug!(
                requested = k,
                cap,
                backend = idx.name(),
                "k trimmed to backend max_k"
            );
            cap
        }
        _ => k,
    }
}

impl<Mode> Store<Mode> {
    /// Defensive guard for query-time embedder/index dim mismatch.
    ///
    /// Returns [`StoreError::QueryDimMismatch`] when the query embedding's
    /// dimension differs from the index's recorded dimension.
    ///
    /// # Why this exists
    ///
    /// Without this guard, a query against an index built with a different
    /// model silently returns zero results: the dim mismatch surfaces as a
    /// `tracing::warn!` deep inside the index backend (HNSW or CAGRA) and the
    /// caller sees an empty result set with no actionable signal.
    ///
    /// Index-aware embedder resolution (`ModelConfig::resolve_for_query`) makes
    /// this case rare; the guard is a defensive net for the "no recorded model"
    /// / corrupt-meta edge case.
    pub(crate) fn check_query_dim(&self, query: &Embedding) -> Result<(), StoreError> {
        if query.len() == self.dim {
            return Ok(());
        }
        let index_model = self
            .stored_model_name()
            .unwrap_or_else(|| "<unknown>".to_string());
        // We do not have the embedder here, so we can only describe the query
        // dim. The error template still gives the user enough information to
        // fix the situation (rebuild against current model, or set
        // CQS_EMBEDDING_MODEL to the indexed model).
        let query_model = format!("{}-dim embedder", query.len());
        Err(StoreError::QueryDimMismatch {
            index_dim: self.dim,
            query_dim: query.len(),
            index_model,
            query_model,
        })
    }

    /// Get the cached note-boost index, building from
    /// [`Store::cached_notes_summaries`] on first access or after invalidation.
    ///
    /// The boost cache itself lives on the store as an opaque
    /// [`crate::search::scoring::NoteBoostCache`] cell; this accessor is the
    /// search-side bridge that supplies the notes (store-owned data) and
    /// drives the build (search-owned scoring). The owned index is computed
    /// once per notes-table revision and shared via `Arc` across all search
    /// paths, avoiding an O(notes × mentions) rebuild on every search.
    pub(crate) fn cached_note_boost_index(
        &self,
    ) -> Result<std::sync::Arc<super::scoring::OwnedNoteBoostIndex>, StoreError> {
        let _span = tracing::debug_span!("cached_note_boost_index").entered();
        let notes = self.cached_notes_summaries()?;
        Ok(self.note_boost_cache.get_or_build(&notes))
    }

    /// Searches for embeddings matching a query with optional filtering and ranking.
    ///
    /// # Arguments
    ///
    /// * `query` - The embedding vector to search for
    /// * `filter` - Search filter configuration including path patterns, RRF settings, and demotion rules
    /// * `limit` - Maximum number of results to return
    /// * `threshold` - Minimum similarity score threshold for results
    ///
    /// # Returns
    ///
    /// A vector of search results ranked by relevance, containing up to `limit` entries that exceed the similarity threshold.
    ///
    /// # Errors
    ///
    /// Returns `StorageError` if loading cached note summaries fails or if the underlying search operation encounters a storage error.
    pub fn search_filtered(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
    ) -> Result<Vec<SearchResult>, StoreError> {
        let _span =
            tracing::info_span!("search_filtered", limit, threshold, rrf = filter.enable_rrf)
                .entered();
        // Defensive guard: surface query/index dim mismatch as a structured
        // error instead of returning empty results from a downstream
        // tracing::warn!. Rarely triggers given index-aware embedder
        // resolution, but it remains a safety net for missing-stored-model /
        // corrupt-meta edges.
        self.check_query_dim(query)?;
        // Load notes once for note-boosted ranking (cheap — no embeddings)
        let notes = match self.cached_notes_summaries() {
            Ok(n) => n,
            Err(e) => {
                // Rust 1.95: hint that the notes-load failure path is cold so
                // the optimizer keeps the happy path (Ok) inline in this
                // per-query function.
                core::hint::cold_path();
                tracing::warn!(error = %e, "Failed to load notes for search boosting");
                std::sync::Arc::new(Vec::new())
            }
        };
        self.search_filtered_with_notes(query, filter, limit, threshold, &notes)
    }

    /// Inner implementation of `search_filtered` that accepts pre-loaded notes.
    fn search_filtered_with_notes(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
        notes: &[NoteSummary],
    ) -> Result<Vec<SearchResult>, StoreError> {
        // No nested `info_span!("search_filtered", ...)` here — the
        // `search_filtered` public wrapper already opens the span for the whole
        // call tree, and `search_filtered_with_index` opens its own
        // `search_index_guided` span first. A duplicate span here would double
        // the per-query span allocation on the hottest daemon path and make
        // flame graphs look like recursion.
        self.block_on(async {
            let fsql = build_filter_sql(filter);
            // Saturating mul matches sibling paths; overflow on pathological
            // `limit` would panic in debug.
            let semantic_limit = if fsql.use_rrf {
                limit.saturating_mul(3)
            } else {
                limit
            };
            let need_name = fsql.use_hybrid || filter.enable_demotion;

            // Compile glob pattern once outside the loop (not per-chunk).
            // Note: Invalid patterns are logged and silently ignored (returns all results).
            // Callers should validate patterns upfront via SearchFilter::validate() if they
            // want to reject invalid patterns. This lenient behavior is intentional to allow
            // partial searches when users provide malformed patterns interactively.
            let glob_matcher = compile_glob_filter(filter.path_pattern.as_ref());

            // Pre-tokenize query for name matching (avoids re-tokenizing per result)
            let name_matcher = if fsql.use_hybrid {
                Some(NameMatcher::new(&filter.query_text))
            } else {
                None
            };

            // Use the cached `OwnedNoteBoostIndex` instead of rebuilding from
            // `&[NoteSummary]` on every search. Falls back to a fresh borrowed
            // build if the cache fetch fails (transient — can happen if the
            // notes query errors).
            let note_boost = match self.cached_note_boost_index() {
                Ok(arc) => NoteBoost::Owned(arc),
                Err(e) => {
                    // Cache-fetch failure is the cold path; the borrowed
                    // rebuild only fires when the cache layer has already
                    // failed once.
                    core::hint::cold_path();
                    tracing::warn!(error = %e, "note boost cache unavailable, rebuilding per-call");
                    NoteBoost::Borrowed(super::scoring::NoteBoostIndex::new(notes))
                }
            };

            // Build loop-invariant scoring context once
            let scoring_ctx = ScoringContext {
                query: query.as_slice(),
                filter,
                name_matcher: name_matcher.as_ref(),
                glob_matcher: glob_matcher.as_ref(),
                note_index: &note_boost,
                threshold,
            };

            // Use bounded heap to maintain only top-N results during iteration.
            // This bounds memory to O(semantic_limit) instead of O(total_chunks).
            let mut score_heap = BoundedScoreHeap::new(semantic_limit);

            // Cursor-based batching: bound memory to O(batch_size) instead of
            // O(total_chunks). Uses the same cursor pattern as
            // EmbeddingBatchIterator in store/chunks.rs.
            //
            // Scale by query dim so a 4096-dim model doesn't hold 80 MB per
            // batch in memory while a 768-dim model only holds 15. 5000 is the
            // BGE-large baseline (1024-dim); at 768-dim that's 6_666 (clamped
            // to 50_000), at 4096-dim it's 1_250 (above the 500 floor). Env
            // override: CQS_BRUTE_FORCE_BATCH_SIZE wins.
            let brute_force_batch_size: i64 = std::env::var("CQS_BRUTE_FORCE_BATCH_SIZE")
                .ok()
                .and_then(|v| v.parse::<i64>().ok())
                .filter(|&n| n > 0)
                .unwrap_or_else(|| {
                    crate::limits::dim_scaled_batch(5000, query.len(), 500, 50_000) as i64
                });
            let mut last_rowid: i64 = 0;

            // Hoist SQL template out of cursor loop — only last_rowid changes per iteration
            let rowid_condition = format!("rowid > ?{}", fsql.bind_values.len() + 1);
            let limit_param = format!("?{}", fsql.bind_values.len() + 2);
            let batch_where = if fsql.conditions.is_empty() {
                format!(
                    " WHERE {} ORDER BY rowid ASC LIMIT {}",
                    rowid_condition, limit_param
                )
            } else {
                format!(
                    " WHERE {} AND {} ORDER BY rowid ASC LIMIT {}",
                    fsql.conditions.join(" AND "),
                    rowid_condition,
                    limit_param
                )
            };
            let sql = format!("SELECT {} FROM chunks{}", fsql.columns, batch_where);

            loop {
                let batch = self
                    .fetch_brute_force_batch(
                        sql.as_str(),
                        &fsql.bind_values,
                        last_rowid,
                        brute_force_batch_size,
                        need_name,
                    )
                    .await?;

                if batch.is_empty() {
                    break;
                }
                last_rowid = batch.last().expect("batch non-empty checked above").rowid;

                for row in &batch {
                    let embedding = match embedding_slice(&row.embedding_bytes, self.dim) {
                        Ok(e) => e,
                        Err(_) => continue,
                    };
                    // Use the authoritative `origin` column, matching the
                    // indexed path (which scores from `candidate.origin`).
                    if let Some(score) =
                        score_candidate(embedding, row.name.as_deref(), &row.origin, &scoring_ctx)
                    {
                        score_heap.push(row.id.clone(), score);
                    }
                }
            }

            let scored = score_heap.into_sorted_vec();

            let signal_inputs = filter.record_rank_signals.then_some(RankSignalInputs {
                note_index: &note_boost,
                name_matcher: name_matcher.as_ref(),
                enable_demotion: filter.enable_demotion,
                suppress_note_boost: filter.suppress_note_boost,
                // Brute-force scan has no sparse leg.
                sparse_ranks: HashMap::new(),
            });
            let results = self
                .finalize_results(
                    scored,
                    &filter.query_text,
                    fsql.use_rrf,
                    limit,
                    glob_matcher.as_ref(),
                    filter.type_boost_types.as_deref(),
                    filter.mmr_lambda,
                    signal_inputs,
                )
                .await?;

            tracing::debug!(count = results.len(), "search_filtered complete");
            Ok(results)
        })
    }

    /// Post-scoring pipeline: RRF fusion, content fetch, parent dedup, boost, truncate.
    ///
    /// Shared by `search_filtered` and `search_by_candidate_ids`. Both produce
    /// `Vec<(chunk_id, score)>` through different scoring paths (brute-force vs
    /// index-guided), then converge here for the same finalization steps.
    ///
    /// When `use_rrf` is true, fuses semantic rankings with FTS keyword results
    /// via Reciprocal Rank Fusion before fetching full content. Requests `limit * 2`
    /// candidates from RRF to compensate for parent dedup filtering.
    #[allow(clippy::too_many_arguments)] // The function joins per-call routing flags (use_rrf, mmr_lambda) with per-search inputs; the optional signal_inputs rides the same shape.
    async fn finalize_results(
        &self,
        mut scored: Vec<(String, f32)>,
        query_text: &str,
        use_rrf: bool,
        limit: usize,
        glob_matcher: Option<&globset::GlobMatcher>,
        type_boost_types: Option<&[ChunkType]>,
        mmr_lambda: Option<f32>,
        signal_inputs: Option<RankSignalInputs<'_>>,
    ) -> Result<Vec<SearchResult>, StoreError> {
        // Resolve MMR setting: explicit `filter.mmr_lambda` wins, else env
        // var, else None (disabled).
        //
        // Surface-feature MMR (path + name) is net-negative on v3 test for
        // non-RRF queries — sweeps regressed R@5 by 3-9pp at every λ ∈ [0.7,
        // 0.95], root cause unconfirmed. Code is plumbed but inert under the
        // default pool size; opt-in only. For RRF queries (pool = `limit * 2`)
        // MMR has more to work with but isn't A/B'd yet. Embedding-based
        // similarity (instead of surface features) is the other obvious
        // direction.
        let mmr_lambda = mmr_lambda.or_else(mmr_lambda_from_env);

        // Ranking-provenance leg ranks. Built from the *incoming* `scored`
        // (the semantic/base ordering fed to fusion) before it is consumed
        // below, plus the FTS leg captured inside the RRF branch. Owned keys
        // so the maps outlive the `scored`/`fts_ids` borrows; populated only
        // when recording is on. This is a read-only mirror of the inputs the
        // scoring already consumed — it never feeds back into a score.
        let mut dense_ranks: HashMap<String, usize> = HashMap::new();
        let mut fts_ranks: HashMap<String, usize> = HashMap::new();
        if signal_inputs.is_some() {
            for (rank, (id, _)) in scored.iter().enumerate() {
                // 1-indexed to match the RRF rank convention (first = rank 1).
                dense_ranks.entry(id.clone()).or_insert(rank + 1);
            }
        }

        // Step 1: RRF fusion with FTS keyword search, or plain truncate
        let final_scored: Vec<(String, f32)> = if use_rrf {
            let normalized = normalize_for_fts(query_text);
            let sanitized = sanitize_fts_query(&normalized);
            let expanded = expand_query_for_fts(&sanitized);
            let fts_query = if expanded.is_empty() {
                sanitized.clone()
            } else {
                expanded
            };
            let fts_ids = if fts_query.is_empty() {
                vec![]
            } else {
                tracing::debug!(fts_query = %fts_query, "FTS MATCH query");
                // Shared gated FTS query (`fts_match_id_origins`) — carries the
                // `needs_embedding = 0` visibility gate so zero-vec sentinel
                // chunks from a partial `--llm-summaries` reindex can't
                // surface through the keyword leg. Returns each id with its
                // `origin` (authoritative file path) for the glob filter.
                let fts_all: Vec<(String, String)> = self
                    .fts_match_id_origins(&fts_query, limit.saturating_mul(3))
                    .await?;
                // Apply path filter to FTS results (the glob filter isn't
                // expressible in the FTS query). Glob matches the real `origin`,
                // never a substring parsed out of the id. Reuses the caller's
                // pre-compiled glob matcher.
                if let Some(gm) = glob_matcher {
                    fts_all
                        .into_iter()
                        .filter(|(_id, origin)| gm.is_match(origin))
                        .map(|(id, _origin)| id)
                        .collect()
                } else {
                    fts_all.into_iter().map(|(id, _origin)| id).collect()
                }
            };
            if signal_inputs.is_some() {
                // FTS leg ranks mirror `rrf_fuse`'s per-list dedup: first
                // occurrence wins (1-indexed). Records the exact rank the
                // fusion math used for this leg.
                for (rank, id) in fts_ids.iter().enumerate() {
                    fts_ranks.entry(id.clone()).or_insert(rank + 1);
                }
            }
            let semantic_ids: Vec<&str> = scored.iter().map(|(id, _)| id.as_str()).collect();
            // Request extra candidates from RRF to compensate for parent dedup
            // filtering below — dedup can drop results, leaving fewer than `limit`.
            // Saturating mul to match sibling paths.
            rrf_fuse(&semantic_ids, &fts_ids, limit.saturating_mul(2))
        } else {
            scored.truncate(limit);
            scored
        };

        if final_scored.is_empty() {
            return Ok(vec![]);
        }

        // Step 2: Fetch full content only for top-N results — heavy
        // content/doc/signature columns loaded only for winners.
        let ids: Vec<&str> = final_scored.iter().map(|(id, _)| id.as_str()).collect();
        let mut rows_map = self.fetch_chunks_by_ids_async(&ids).await?;

        // Step 3: Parent dedup — keep first occurrence per parent_id.
        // remove() instead of get()+clone() avoids copying 10+ Strings per result.
        let mut seen_parents: HashSet<String> = HashSet::new();
        let mut results: Vec<SearchResult> = final_scored
            .into_iter()
            .filter_map(|(id, score)| {
                let row = rows_map.remove(&id)?;
                let dedup_key = row.parent_id.clone().unwrap_or_else(|| row.id.clone());
                if seen_parents.insert(dedup_key) {
                    Some(SearchResult::new(ChunkSummary::from(row), score))
                } else {
                    None
                }
            })
            .collect();

        // Step 4: Boost container chunks when multiple child methods appear.
        // Returns the per-result boost multiplier for the `rank_signals`
        // recorder — empty when no container was boosted.
        let parent_boosts = apply_parent_boost(&mut results);

        // Step 4b: Type boost from adaptive routing.
        //
        // Default 1.2x for matching types, overridable via CQS_TYPE_BOOST env
        // var so the knob can be swept without rebuilding the binary. See
        // docs/plans/adaptive-retrieval.md.
        //
        // Boost is multiplicative (not additive) so it stays scale-invariant
        // across cosine [0,1] and re-ranker scores [-inf, inf]. Boost == 1.0
        // is the no-op default for callers that haven't opted in via
        // type_boost_types.
        if let Some(boost_types) = type_boost_types {
            let boost = type_boost_factor();
            for result in &mut results {
                if boost_types.contains(&result.chunk.chunk_type) {
                    result.score *= boost;
                }
            }
            // Re-sort after boost. Secondary sort on chunk id keeps ties
            // deterministic across process invocations.
            results.sort_by(|a, b| {
                b.score
                    .total_cmp(&a.score)
                    .then(a.chunk.id.cmp(&b.chunk.id))
            });
        }

        // Step 4c: MMR re-rank for diversity (opt-in via SearchFilter or
        // CQS_MMR_LAMBDA env). Runs against the full post-dedup pool, which
        // is up to `limit * 2` (RRF) or `limit * 3` (no-RRF expansion). Pure
        // no-op when lambda is None or >= 1.0 — see `search::mmr::mmr_rerank`.
        if let Some(lambda) = mmr_lambda {
            if lambda < 1.0 && results.len() > limit {
                let cands: Vec<MmrCandidate<'_>> = results
                    .iter()
                    .map(|r| MmrCandidate {
                        id: r.chunk.id.as_str(),
                        score: r.score,
                        file: r.chunk.file.as_path(),
                        name: r.chunk.name.as_str(),
                    })
                    .collect();
                let picks = mmr_rerank(&cands, limit, lambda);
                let mut reordered: Vec<SearchResult> = Vec::with_capacity(picks.len());
                let mut taken = vec![false; results.len()];
                for &i in &picks {
                    taken[i] = true;
                }
                // Drain in pick order without cloning SearchResult.
                let originals: Vec<Option<SearchResult>> =
                    results.into_iter().map(Some).collect::<Vec<_>>();
                let mut originals = originals;
                for &i in &picks {
                    if let Some(r) = originals[i].take() {
                        reordered.push(r);
                    }
                }
                results = reordered;
                tracing::debug!(lambda = lambda, picked = picks.len(), "MMR re-rank applied");
            }
        }

        // Step 5: Truncate back to requested limit after parent dedup
        results.truncate(limit);

        // Step 6: Ranking provenance (opt-in). A pure post-pass over the final
        // result set — scores and order are already fixed, so recording here
        // is provably bit-identical to not recording. The leg rank maps and
        // the caller's boost-lookup inputs reconstruct each signal's native
        // contribution. Skip-when-empty: results with no recorded signal carry
        // no `rank_signals` on the wire.
        if let Some(inputs) = signal_inputs {
            let dense_ref: HashMap<&str, usize> =
                dense_ranks.iter().map(|(k, v)| (k.as_str(), *v)).collect();
            let fts_ref: HashMap<&str, usize> =
                fts_ranks.iter().map(|(k, v)| (k.as_str(), *v)).collect();
            let sparse_ref: HashMap<&str, usize> = inputs
                .sparse_ranks
                .iter()
                .map(|(k, v)| (k.as_str(), *v))
                .collect();
            let parent_boost_ref: HashMap<&str, f32> = parent_boosts
                .iter()
                .map(|(k, v)| (k.as_str(), *v))
                .collect();
            let signal_ctx = RankSignalCtx {
                note_index: inputs.note_index,
                name_matcher: inputs.name_matcher,
                enable_demotion: inputs.enable_demotion,
                type_boost_types,
                type_boost_factor: type_boost_factor(),
                dense_ranks: &dense_ref,
                fts_ranks: &fts_ref,
                sparse_ranks: &sparse_ref,
                is_rrf: use_rrf,
                suppress_note_boost: inputs.suppress_note_boost,
                parent_boosts: &parent_boost_ref,
            };
            for r in &mut results {
                r.rank_signals = signals_for(r, &signal_ctx);
            }
        }

        Ok(results)
    }

    /// Search with optional SPLADE sparse-dense fusion and vector-index
    /// candidate retrieval.
    ///
    /// When `splade` is Some and `filter.enable_splade` is true, fuses dense
    /// (cosine) and sparse (SPLADE) results via linear interpolation.
    pub fn search_hybrid(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
        index: Option<&dyn VectorIndex>,
        splade: Option<(
            &crate::splade::index::SpladeIndex,
            &crate::splade::SparseVector,
        )>,
    ) -> Result<Vec<SearchResult>, StoreError> {
        // Thin wrapper: production callers never pay for leg capture. The legs
        // arm allocates nothing when `capture_legs == false`, so the fused
        // result this returns is byte-for-byte the historical output.
        let (results, _legs) =
            self.search_hybrid_inner(query, filter, limit, threshold, index, splade, false)?;
        Ok(results)
    }

    /// Like [`Self::search_hybrid`], but additionally surfaces the three
    /// pre-fusion retrieval legs (dense cosine, sparse SPLADE, fused) for the
    /// SPLADE inspector, WITHOUT changing the fused result.
    ///
    /// Returns `(results, Some(legs))` when the SPLADE fusion path actually
    /// ran, and `(results, None)` when it short-circuited to dense-only
    /// retrieval (SPLADE disabled in the filter, or no SPLADE index supplied) —
    /// the three-leg view is only meaningful when fusion happened, so the
    /// `None` is honest rather than a synthesized degenerate leg set.
    ///
    /// The `results` returned here are identical to what
    /// [`Self::search_hybrid`] returns for the same arguments; the legs are a
    /// pure side channel captured during fusion.
    pub fn search_hybrid_legs(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
        index: Option<&dyn VectorIndex>,
        splade: Option<(
            &crate::splade::index::SpladeIndex,
            &crate::splade::SparseVector,
        )>,
    ) -> Result<(Vec<SearchResult>, Option<SearchLegs>), StoreError> {
        self.search_hybrid_inner(query, filter, limit, threshold, index, splade, true)
    }

    /// Shared body for [`Self::search_hybrid`] and [`Self::search_hybrid_legs`].
    ///
    /// `capture_legs` gates EVERY leg allocation: when `false` the function
    /// runs the exact historical fusion code and returns `None` legs, so the
    /// production hot path (`search_hybrid`) is unchanged. When `true` it
    /// additionally materializes the three pre-fusion rankings during the same
    /// fusion pass — so the captured `fused` leg is, by construction, the same
    /// ranking the result list is built from, not a parallel recomputation.
    #[allow(clippy::too_many_arguments)]
    fn search_hybrid_inner(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
        index: Option<&dyn VectorIndex>,
        splade: Option<(
            &crate::splade::index::SpladeIndex,
            &crate::splade::SparseVector,
        )>,
        capture_legs: bool,
    ) -> Result<(Vec<SearchResult>, Option<SearchLegs>), StoreError> {
        // The "not enabled OR no index" guard and the `splade` unwrap collapse
        // into a single match so the unwrap branch is structurally impossible.
        // The dense-only fallbacks carry `None` legs: the three-leg view is
        // only defined when SPLADE fusion ran.
        let (splade_index, sparse_query) = match (filter.enable_splade, splade) {
            (false, _) => {
                let results =
                    self.search_filtered_with_index(query, filter, limit, threshold, index)?;
                return Ok((results, None));
            }
            (true, None) => {
                tracing::debug!(
                    "SPLADE requested but index unavailable, falling back to dense-only search"
                );
                let results =
                    self.search_filtered_with_index(query, filter, limit, threshold, index)?;
                return Ok((results, None));
            }
            (true, Some(s)) => s,
        };
        let _span = tracing::info_span!("search_hybrid", limit, enable_splade = true).entered();

        // Load notes once for all paths
        let notes = match self.cached_notes_summaries() {
            Ok(n) => n,
            Err(e) => {
                tracing::warn!(error = %e, "Failed to load notes for search boosting");
                std::sync::Arc::new(Vec::new())
            }
        };

        // Dense-retrieval pool floor 500. `cqs::limits::candidate_count_for`
        // enforces the floor, honors `CQS_SEARCH_CANDIDATE_FLOOR`, and
        // saturating-muls to guard pathological `limit >= usize::MAX / 5`.
        let candidate_count = candidate_count_for(limit);

        // Build chunk filter predicate
        let meta = self.chunk_type_language_map()?;
        let include_types = filter.include_types.as_ref();
        let exclude_types = filter.exclude_types.as_ref();
        let languages = filter.languages.as_ref();
        let predicate = |chunk_id: &str| -> bool {
            if include_types.is_none() && exclude_types.is_none() && languages.is_none() {
                return true;
            }
            if let Some((ct, lang)) = meta.get(chunk_id) {
                let type_ok = include_types.is_none_or(|types| types.contains(ct));
                let exclude_ok = exclude_types.is_none_or(|types| !types.contains(ct));
                let lang_ok = languages.is_none_or(|langs| langs.contains(lang));
                type_ok && exclude_ok && lang_ok
            } else {
                false
            }
        };

        // Dense results from vector index (HNSW or CAGRA)
        let dense_results = if let Some(idx) = index {
            // Cap k at the backend's max_k. CAGRA reports an itopk-derived
            // cap (~441 at 14k chunks) above which the GPU search returns
            // an empty Vec; without trimming, a candidate_count of 500
            // collapses the dense leg to zero results and α-fusion of
            // 1.0·0 + 0.0·s = 0 produces near-random R@K. HNSW reports
            // None and is unaffected.
            let effective_k = cap_k_to_backend(idx, candidate_count);
            idx.search_with_filter(query, effective_k, &predicate)
        } else {
            // Rust 1.95: hybrid search without a vector index is a misconfig
            // (build error or partial index load). Mark cold so the dense
            // leg's normal path stays the predicted branch.
            core::hint::cold_path();
            tracing::warn!("No vector index available for dense leg of hybrid search");
            Vec::new()
        };

        // Sparse results from SPLADE inverted index
        let sparse_results =
            splade_index.search_with_filter(sparse_query, candidate_count, &predicate);

        tracing::debug!(
            dense = dense_results.len(),
            sparse = sparse_results.len(),
            "Hybrid search: fusing results"
        );

        // Normalize sparse scores to [0, 1] via min-max. Reduce-from-first
        // (not fold-from-0.0) so a cohort whose maximum score is < 0 doesn't
        // get its max dominated by the seed. SPLADE is non-negative (ReLU on
        // logits), but a future sparse signal that isn't (learned dot-product,
        // BM25-like delta) would silently degenerate to dense-only retrieval.
        // When max is non-positive we also warn so eval/CI catches the collapse.
        let max_sparse = sparse_results
            .iter()
            .map(|r| r.score)
            .reduce(f32::max)
            .unwrap_or(0.0);
        if !sparse_results.is_empty() && max_sparse <= 0.0 {
            tracing::warn!(
                count = sparse_results.len(),
                max = max_sparse,
                "Sparse cohort has non-positive max; min-max normalization will zero every entry"
            );
        }

        // Pre-size hash containers from known input counts. Upper bound on
        // unique candidates is |dense| + |sparse|; default construction would
        // rehash 2-3 times growing to this size on every SPLADE query.
        let dense_len = dense_results.len();
        let sparse_len = sparse_results.len();
        let union_upper = dense_len + sparse_len;

        // Build score maps
        let mut dense_scores: std::collections::HashMap<&str, f32> =
            std::collections::HashMap::with_capacity(dense_len);
        for r in &dense_results {
            dense_scores.insert(&r.id, r.score);
        }
        let mut sparse_scores: std::collections::HashMap<&str, f32> =
            std::collections::HashMap::with_capacity(sparse_len);
        for r in &sparse_results {
            let normalized = if max_sparse > 0.0 {
                r.score / max_sparse
            } else {
                0.0
            };
            sparse_scores.insert(&r.id, normalized);
        }

        // Union of all candidate IDs, deduped by insertion order for determinism.
        // Dense results are inserted first so they bias tie-breaking ahead of
        // sparse-only candidates. Using a HashSet here made iteration order
        // process-seed-random, which flipped equal-score candidates at the
        // truncate() boundary between runs.
        let mut all_ids: Vec<&str> = Vec::with_capacity(union_upper);
        let mut seen_ids: std::collections::HashSet<&str> =
            std::collections::HashSet::with_capacity(union_upper);
        for r in &dense_results {
            if seen_ids.insert(&r.id) {
                all_ids.push(&r.id);
            }
        }
        for r in &sparse_results {
            if seen_ids.insert(&r.id) {
                all_ids.push(&r.id);
            }
        }

        // Fuse with linear interpolation: final = α * dense + (1-α) * sparse
        let alpha = filter.splade_alpha;
        tracing::debug!(
            alpha,
            dense = dense_scores.len(),
            sparse = sparse_scores.len(),
            "SPLADE fusion"
        );
        let mut fused: Vec<crate::index::IndexResult> = all_ids
            .iter()
            .map(|id| {
                let d = dense_scores.get(id).copied().unwrap_or(0.0);
                let s = sparse_scores.get(id).copied().unwrap_or(0.0);
                let score = if alpha <= 0.0 {
                    // Pure re-rank mode: keep the dense cosine as the dominant
                    // signal and let SPLADE nudge it within the same band. A
                    // naive `1.0 + s` would put SPLADE-found chunks in
                    // `[1.0, 2.0]` while dense-only chunks stay in cosine
                    // `[-1, 1]`, letting any positive sparse signal beat every
                    // dense match.
                    let boost = s * 0.1;
                    d + boost
                } else {
                    alpha * d + (1.0 - alpha) * s
                };
                crate::index::IndexResult {
                    id: id.to_string(),
                    score,
                }
            })
            .collect();
        // Secondary sort key on id ensures equal-score candidates have a
        // deterministic order across process invocations. Without this, the
        // truncate() below drops different candidates on each run.
        fused.sort_by(|a, b| b.score.total_cmp(&a.score).then(a.id.cmp(&b.id)));
        fused.truncate(candidate_count);

        tracing::debug!(fused = fused.len(), alpha, "Hybrid fusion complete");

        // Build `candidate_ids` from the sorted `fused` Vec BEFORE populating
        // the score-lookup HashMap so the positional order of candidate_ids
        // matches the fusion sort. Building it from `fused_map.keys()` instead
        // would scramble the tie-broken sort order (HashMap iteration order is
        // unspecified). The downstream re-sort in
        // `search_by_candidate_ids_with_notes` recovers final ranking, but
        // "candidate_ids order == fusion order" is load-bearing for any
        // tie-breaker that uses positional rank.
        //
        // Cost: N clones of chunk-id strings (typically ~32 hex chars,
        // N <= candidate_count ~= 200). Negligible vs the search work.
        let candidate_ids: Vec<&str> = fused.iter().map(|r| r.id.as_str()).collect();
        let mut fused_map: std::collections::HashMap<String, f32> =
            std::collections::HashMap::with_capacity(fused.len());
        for r in fused.iter() {
            fused_map.insert(r.id.clone(), r.score);
        }

        // Capture the three pre-fusion legs for the SPLADE inspector. Gated
        // entirely behind `capture_legs`: when false NOTHING below this point
        // allocates, so the fused result the production path returns is
        // byte-identical. The legs are built from the SAME `dense_results`,
        // `sparse_results`, `max_sparse`, and `fused` the fusion just used, so
        // they describe the exact ranking the result list is derived from.
        let legs = if capture_legs {
            Some(build_search_legs(
                &dense_results,
                &dense_scores,
                &sparse_results,
                &sparse_scores,
                max_sparse,
                &all_ids,
                &fused,
            ))
        } else {
            None
        };

        // Capture the sparse leg's per-result rank for the `rank_signals`
        // recorder. `sparse_results` is already sorted by sparse score
        // descending (BoundedScoreHeap::into_sorted_vec), so the enumeration
        // index is the 1-indexed leg rank — the same shape as the dense/FTS leg
        // maps `finalize_results` builds. Only materialized when recording is
        // on; this is a side channel that never feeds the score.
        let sparse_ranks = if filter.record_rank_signals {
            let mut m: std::collections::HashMap<String, usize> =
                std::collections::HashMap::with_capacity(sparse_results.len());
            for (rank, r) in sparse_results.iter().enumerate() {
                m.entry(r.id.clone()).or_insert(rank + 1);
            }
            Some(m)
        } else {
            None
        };

        let results = self.search_by_candidate_ids_with_notes(
            &candidate_ids,
            query,
            filter,
            limit,
            threshold,
            &notes,
            Some(&fused_map),
            sparse_ranks,
        )?;
        Ok((results, legs))
    }

    pub fn search_filtered_with_index(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
        index: Option<&dyn VectorIndex>,
    ) -> Result<Vec<SearchResult>, StoreError> {
        // Defensive guard for query/index dim mismatch — see `check_query_dim`
        // for rationale. Index backends (HNSW, CAGRA) otherwise swallow this
        // case as a `tracing::warn!` and return zero candidates.
        self.check_query_dim(query)?;
        // Load notes once for all search paths.
        let notes = match self.cached_notes_summaries() {
            Ok(n) => n,
            Err(e) => {
                tracing::warn!(error = %e, "Failed to load notes for search boosting");
                std::sync::Arc::new(Vec::new())
            }
        };

        if let Some(idx) = index {
            let _span = tracing::info_span!("search_index_guided", limit = limit).entered();

            // See `search_hybrid` for the floor rationale +
            // CQS_SEARCH_CANDIDATE_FLOOR override.
            let candidate_count = candidate_count_for(limit);
            let has_type_or_lang_filter = filter.include_types.is_some()
                || filter.exclude_types.is_some()
                || filter.languages.is_some();

            // Cap to backend max_k (see `cap_k_to_backend` doc): CAGRA's
            // itopk_max enforces an effective ceiling above which it returns
            // empty results. Apply once for both filtered and unfiltered
            // arms below.
            let effective_k = cap_k_to_backend(idx, candidate_count);
            let index_results = if has_type_or_lang_filter {
                // Build traversal-time filter from chunk metadata
                let meta = self.chunk_type_language_map()?;
                let include_types = filter.include_types.as_ref();
                let exclude_types = filter.exclude_types.as_ref();
                let languages = filter.languages.as_ref();
                let predicate = |chunk_id: &str| -> bool {
                    if let Some((ct, lang)) = meta.get(chunk_id) {
                        let type_ok = include_types.is_none_or(|types| types.contains(ct));
                        let exclude_ok = exclude_types.is_none_or(|types| !types.contains(ct));
                        let lang_ok = languages.is_none_or(|langs| langs.contains(lang));
                        type_ok && exclude_ok && lang_ok
                    } else {
                        false
                    }
                };
                idx.search_with_filter(query, effective_k, &predicate)
            } else {
                idx.search(query, effective_k)
            };

            if index_results.is_empty() {
                // Structured fields so the brute-force fallback is attributable
                // per call without having to grep the message.
                tracing::info!(
                    query_dim = query.len(),
                    limit,
                    "Index returned no candidates — falling back to brute-force scan"
                );
                return self.search_filtered_with_notes(query, filter, limit, threshold, &notes);
            }

            tracing::debug!(
                candidates = index_results.len(),
                "Vector index returned candidates"
            );

            let candidate_ids: Vec<&str> = index_results.iter().map(|r| r.id.as_str()).collect();

            // The index already computed the dense cosine for every candidate;
            // when its score scale is exactly the brute-force cosine
            // (`index_scores_are_cosine`), reuse those scores as the fused base
            // instead of re-fetching each embedding BLOB and recomputing the
            // identical dot product. The downstream scoring pipeline still
            // applies name/note boost, demotion, and threshold on top — passing
            // the scores via `fused_scores` only replaces the base, so the
            // ranking is unchanged. Backends whose scale differs (CAGRA cosine
            // via L2Expanded, any DotProduct metric) report `false` and keep
            // the recompute path to avoid a silent ranking shift.
            let reuse_scores = idx.index_scores_are_cosine();
            let fused_scores: Option<std::collections::HashMap<String, f32>> = if reuse_scores {
                Some(
                    index_results
                        .iter()
                        .map(|r| (r.id.clone(), r.score))
                        .collect(),
                )
            } else {
                None
            };

            return self.search_by_candidate_ids_with_notes(
                &candidate_ids,
                query,
                filter,
                limit,
                threshold,
                &notes,
                fused_scores.as_ref(),
                // Index-guided dense path has no sparse leg.
                None,
            );
        }

        self.search_filtered_with_notes(query, filter, limit, threshold, &notes)
    }

    /// Search within a set of candidate IDs (for HNSW-guided filtered search)
    pub fn search_by_candidate_ids(
        &self,
        candidate_ids: &[&str],
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
    ) -> Result<Vec<SearchResult>, StoreError> {
        // Defensive guard for query/index dim mismatch — see `check_query_dim`.
        self.check_query_dim(query)?;
        // Load notes once for note-boosted ranking
        let notes = match self.cached_notes_summaries() {
            Ok(n) => n,
            Err(e) => {
                tracing::warn!(error = %e, "Failed to load notes for search boosting");
                std::sync::Arc::new(Vec::new())
            }
        };
        self.search_by_candidate_ids_with_notes(
            candidate_ids,
            query,
            filter,
            limit,
            threshold,
            &notes,
            None,
            None,
        )
    }

    /// Inner implementation of `search_by_candidate_ids` that accepts pre-loaded notes
    /// and optional pre-fused scores from hybrid search.
    ///
    /// When `fused_scores` is `Some`, candidates with a fused score entry use that
    /// score as the base (replacing cosine similarity) while still applying name
    /// boost, note boost, demotion, and threshold filtering.
    #[allow(clippy::too_many_arguments)]
    fn search_by_candidate_ids_with_notes(
        &self,
        candidate_ids: &[&str],
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
        notes: &[NoteSummary],
        fused_scores: Option<&std::collections::HashMap<String, f32>>,
        // Per-result sparse (SPLADE) leg rank, threaded from `search_hybrid`
        // for the `rank_signals` recorder. `None` on the non-SPLADE paths.
        sparse_ranks: Option<std::collections::HashMap<String, usize>>,
    ) -> Result<Vec<SearchResult>, StoreError> {
        let _span = tracing::info_span!(
            "search_by_candidates",
            candidates = candidate_ids.len(),
            limit
        )
        .entered();

        if candidate_ids.is_empty() {
            return Ok(vec![]);
        }

        // Reuse flag computation from build_filter_sql to stay consistent.
        let flags = build_filter_sql(filter);
        let use_hybrid = flags.use_hybrid;
        let use_rrf = flags.use_rrf;

        self.block_on(async {
            // Phase 1: Lightweight candidate fetch — only scoring fields.
            // Excludes heavy content/doc/signature columns. The embedding
            // BLOB is fetched only when there are no pre-fused scores:
            // `search_hybrid` builds `candidate_ids` and `fused_scores` from
            // the same fusion result, so fused coverage is total by
            // construction and the cosine fallback below never needs the
            // embedding on that path.
            let candidates = self
                .fetch_candidates_by_ids_async(candidate_ids, fused_scores.is_none())
                .await?;

            // Compile glob pattern once outside the loop (not per-chunk).
            let glob_matcher = compile_glob_filter(filter.path_pattern.as_ref());

            // Pre-tokenize query for name matching (avoids re-tokenizing per result)
            let name_matcher = if use_hybrid {
                Some(NameMatcher::new(&filter.query_text))
            } else {
                None
            };

            // Prefer the cached `OwnedNoteBoostIndex` shared across searches
            // instead of rebuilding per-call. Fall back to a fresh borrowed
            // build on cache fetch failure.
            let note_boost = match self.cached_note_boost_index() {
                Ok(arc) => NoteBoost::Owned(arc),
                Err(e) => {
                    tracing::warn!(error = %e, "note boost cache unavailable, rebuilding per-call");
                    NoteBoost::Borrowed(super::scoring::NoteBoostIndex::new(notes))
                }
            };

            // Build loop-invariant scoring context once
            let scoring_ctx = ScoringContext {
                query: query.as_slice(),
                filter,
                name_matcher: name_matcher.as_ref(),
                glob_matcher: glob_matcher.as_ref(),
                note_index: &note_boost,
                threshold,
            };

            // Pre-build filter sets once — avoids per-candidate string parsing.
            // No `.to_lowercase()` because both `Language` and `ChunkType`
            // Display already emit canonical lowercase. The to_string() still
            // allocates but is unavoidable since HashSet<&str> would borrow
            // from filter.
            let lang_set: Option<HashSet<String>> = filter
                .languages
                .as_ref()
                .map(|langs| langs.iter().map(|l| l.to_string()).collect());
            let type_set: Option<HashSet<String>> = filter
                .include_types
                .as_ref()
                .map(|types| types.iter().map(|t| t.to_string()).collect());

            let mut scored: Vec<(CandidateRow, f32)> = candidates
                .into_iter()
                .filter_map(|(candidate, embedding_bytes)| {
                    // DB values are already canonical lowercase from
                    // Language::to_string / ChunkType::to_string, so use direct
                    // contains on the pre-lowercased set (no per-candidate
                    // `.to_lowercase()`).
                    if let Some(ref langs) = lang_set {
                        if !langs.contains(&candidate.language) {
                            return None;
                        }
                    }

                    if let Some(ref types) = type_set {
                        if !types.contains(&candidate.chunk_type) {
                            return None;
                        }
                    }

                    let score =
                        if let Some(&fused) = fused_scores.and_then(|fs| fs.get(&candidate.id)) {
                            apply_scoring_pipeline(
                                fused,
                                Some(&candidate.name),
                                &candidate.origin,
                                &scoring_ctx,
                            )?
                        } else {
                            // `embedding_bytes` is `Some` whenever
                            // `fused_scores` is `None` (the fetch above keys
                            // on it), and the fused map covers every
                            // candidate id by construction — so a `None`
                            // here means that invariant broke upstream.
                            // Drop the candidate loudly rather than panic.
                            let Some(bytes) = embedding_bytes.as_deref() else {
                                tracing::warn!(
                                    id = %candidate.id,
                                    "candidate missing both fused score and embedding; dropping"
                                );
                                return None;
                            };
                            let embedding = embedding_slice(bytes, self.dim).ok()?;
                            score_candidate(
                                embedding,
                                Some(&candidate.name),
                                &candidate.origin,
                                &scoring_ctx,
                            )?
                        };

                    Some((candidate, score))
                })
                .collect();

            // Secondary sort on candidate id keeps ties deterministic across
            // process invocations.
            scored.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.id.cmp(&b.0.id)));

            let scored: Vec<(String, f32)> =
                scored.into_iter().map(|(c, score)| (c.id, score)).collect();

            let signal_inputs = filter.record_rank_signals.then_some(RankSignalInputs {
                note_index: &note_boost,
                name_matcher: name_matcher.as_ref(),
                enable_demotion: filter.enable_demotion,
                suppress_note_boost: filter.suppress_note_boost,
                sparse_ranks: sparse_ranks.unwrap_or_default(),
            });
            self.finalize_results(
                scored,
                &filter.query_text,
                use_rrf,
                limit,
                glob_matcher.as_ref(),
                filter.type_boost_types.as_deref(),
                filter.mmr_lambda,
                signal_inputs,
            )
            .await
        })
    }

    /// Code-only search returning [`UnifiedResult::Code`] variants.
    ///
    /// Maps `search_filtered_with_index` results into the `UnifiedResult::Code`
    /// variant so callers that dispatch on the unified enum keep compiling.
    /// When an HNSW index is provided, uses O(log n) candidate retrieval.
    pub fn search_code_results(
        &self,
        query: &Embedding,
        filter: &SearchFilter,
        limit: usize,
        threshold: f32,
        index: Option<&dyn VectorIndex>,
    ) -> Result<Vec<crate::store::UnifiedResult>, StoreError> {
        if limit == 0 {
            return Ok(vec![]);
        }

        let _span =
            tracing::info_span!("search_code_results", limit, threshold = %threshold).entered();

        let code_results =
            self.search_filtered_with_index(query, filter, limit, threshold, index)?;

        let unified: Vec<crate::store::UnifiedResult> = code_results
            .into_iter()
            .map(crate::store::UnifiedResult::Code)
            .collect();

        Ok(unified)
    }
}

#[cfg(test)]
mod tests {
    use super::type_boost_factor;
    use crate::parser::{ChunkType, Language};
    use crate::search::scoring::knob;
    use crate::store::helpers::SearchFilter;
    use crate::test_helpers::{mock_embedding, setup_store};
    use std::path::PathBuf;

    /// Constructs a mock `Chunk` with the provided metadata and a placeholder function body.
    ///
    /// # Arguments
    ///
    /// * `name` - The name of the function chunk.
    /// * `file` - The file path where the chunk is located.
    /// * `lang` - The programming language of the chunk.
    /// * `chunk_type` - The type classification of the chunk.
    ///
    /// # Returns
    ///
    /// A new `Chunk` struct with a generated ID based on the file path and content hash, mock function signature and content, and default values for other fields.
    fn make_chunk(
        name: &str,
        file: &str,
        lang: Language,
        chunk_type: ChunkType,
    ) -> crate::parser::Chunk {
        let content = format!("fn {}() {{ /* body */ }}", name);
        let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
        crate::parser::Chunk {
            id: format!("{}:1:{}", file, &hash[..8]),
            file: PathBuf::from(file),
            language: lang,
            chunk_type,
            name: name.to_string(),
            signature: format!("fn {}()", name),
            content,
            doc: None,
            line_start: 1,
            line_end: 5,
            byte_start: 0,
            content_hash: hash,
            canonical_hash: String::new(),
            parent_id: None,
            window_idx: None,
            parent_type_name: None,
            parser_version: 0,
        }
    }

    #[test]
    fn test_search_filtered_language_filter() {
        let (store, _dir) = setup_store();

        let rust_chunk = make_chunk("rust_fn", "src/lib.rs", Language::Rust, ChunkType::Function);
        let py_chunk = make_chunk(
            "py_fn",
            "src/main.py",
            Language::Python,
            ChunkType::Function,
        );
        let emb = mock_embedding(1.0);

        store
            .upsert_chunks_batch(
                &[(rust_chunk, emb.clone()), (py_chunk, emb.clone())],
                Some(12345),
            )
            .unwrap();

        let filter = SearchFilter {
            languages: Some(vec![Language::Rust]),
            ..Default::default()
        };
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].chunk.language, Language::Rust);
    }

    #[test]
    fn test_search_filtered_chunk_type_filter() {
        let (store, _dir) = setup_store();

        let fn_chunk = make_chunk("my_fn", "src/a.rs", Language::Rust, ChunkType::Function);
        let struct_chunk = make_chunk("MyStruct", "src/b.rs", Language::Rust, ChunkType::Struct);
        let emb = mock_embedding(1.0);

        store
            .upsert_chunks_batch(
                &[(fn_chunk, emb.clone()), (struct_chunk, emb.clone())],
                Some(12345),
            )
            .unwrap();

        let filter = SearchFilter {
            include_types: Some(vec![ChunkType::Struct]),
            ..Default::default()
        };
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].chunk.chunk_type, ChunkType::Struct);
    }

    #[test]
    fn test_search_filtered_path_pattern() {
        let (store, _dir) = setup_store();

        let src_chunk = make_chunk("src_fn", "src/lib.rs", Language::Rust, ChunkType::Function);
        let test_chunk = make_chunk(
            "test_fn",
            "tests/test.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let emb = mock_embedding(1.0);

        store
            .upsert_chunks_batch(
                &[(src_chunk, emb.clone()), (test_chunk, emb.clone())],
                Some(12345),
            )
            .unwrap();

        let filter = SearchFilter {
            path_pattern: Some("src/**".to_string()),
            ..Default::default()
        };
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].chunk.name, "src_fn");
    }

    #[test]
    fn test_search_filtered_combined_filters() {
        let (store, _dir) = setup_store();

        let rust_src = make_chunk("rs_src", "src/a.rs", Language::Rust, ChunkType::Function);
        let py_src = make_chunk("py_src", "src/b.py", Language::Python, ChunkType::Function);
        let rust_test = make_chunk("rs_test", "tests/t.rs", Language::Rust, ChunkType::Function);
        let emb = mock_embedding(1.0);

        store
            .upsert_chunks_batch(
                &[
                    (rust_src, emb.clone()),
                    (py_src, emb.clone()),
                    (rust_test, emb.clone()),
                ],
                Some(12345),
            )
            .unwrap();

        let filter = SearchFilter {
            languages: Some(vec![Language::Rust]),
            path_pattern: Some("src/**".to_string()),
            ..Default::default()
        };
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        assert_eq!(results.len(), 1);
        assert_eq!(results[0].chunk.name, "rs_src");
    }

    #[test]
    fn test_search_filtered_rrf_hybrid() {
        let (store, _dir) = setup_store();

        let chunk = make_chunk(
            "handleError",
            "src/err.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(&[(chunk, emb.clone())], Some(12345))
            .unwrap();

        let filter = SearchFilter {
            enable_rrf: true, // Test needs RRF on
            query_text: "error handling".to_string(),
            ..Default::default()
        };
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        assert!(!results.is_empty(), "RRF hybrid should return results");
    }

    /// The RRF keyword leg shares `fts_match_ids`' `needs_embedding = 0`
    /// gate: a zero-vec sentinel chunk (parser-stage write during a
    /// `--llm-summaries` reindex) whose text matches the FTS query must NOT
    /// surface through `search_filtered`'s RRF path. Once enrichment lands a
    /// real embedding (clearing the flag), the chunk surfaces.
    #[test]
    fn test_search_filtered_rrf_skips_unembedded_chunks() {
        let (store, _dir) = setup_store();

        let chunk = make_chunk(
            "zetaUniqueKeyword",
            "src/zeta.rs",
            Language::Rust,
            ChunkType::Function,
        );
        store
            .upsert_embedded_batch(
                &[],
                std::slice::from_ref(&chunk),
                &std::collections::HashMap::new(),
            )
            .unwrap();

        let emb = mock_embedding(1.0);
        let filter = SearchFilter {
            enable_rrf: true,
            query_text: "zetaUniqueKeyword".to_string(),
            ..Default::default()
        };
        // Threshold above zero keeps the zero-vec sentinel out of the
        // semantic leg, so the FTS keyword leg is the only route the
        // unembedded chunk could surface through.
        let results = store.search_filtered(&emb, &filter, 10, 0.1).unwrap();
        assert!(
            results.iter().all(|r| r.chunk.name != "zetaUniqueKeyword"),
            "unembedded chunk must not surface through the RRF keyword leg, got {:?}",
            results.iter().map(|r| &r.chunk.name).collect::<Vec<_>>()
        );

        // Enrichment lands a real embedding and clears needs_embedding; the
        // chunk becomes visible.
        let updates = vec![(
            chunk.id.clone(),
            mock_embedding(1.0),
            Some("hash".to_string()),
        )];
        store.update_embeddings_with_hashes_batch(&updates).unwrap();
        let results = store.search_filtered(&emb, &filter, 10, 0.1).unwrap();
        assert!(
            results.iter().any(|r| r.chunk.name == "zetaUniqueKeyword"),
            "after enrichment clears the flag, the chunk must surface, got {:?}",
            results.iter().map(|r| &r.chunk.name).collect::<Vec<_>>()
        );
    }

    #[test]
    fn test_search_filtered_name_boost() {
        let (store, _dir) = setup_store();

        let c1 = make_chunk(
            "parseConfig",
            "src/a.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let c2 = make_chunk("renderUI", "src/b.rs", Language::Rust, ChunkType::Function);
        let emb = mock_embedding(1.0);

        store
            .upsert_chunks_batch(&[(c1, emb.clone()), (c2, emb.clone())], Some(12345))
            .unwrap();

        // With name_boost, parseConfig should rank higher for query "parse"
        let filter = SearchFilter {
            name_boost: 0.3,
            query_text: "parseConfig".to_string(),
            ..Default::default()
        };
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        assert!(!results.is_empty());
        // The chunk whose name matches query text should rank first
        assert_eq!(results[0].chunk.name, "parseConfig");
    }

    #[test]
    fn test_search_filtered_empty_store() {
        let (store, _dir) = setup_store();
        let emb = mock_embedding(1.0);
        let filter = SearchFilter::default();
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        assert!(results.is_empty());
    }

    /// TC-ADV-V1.33-1: `search_filtered` with `limit=0` must return an empty
    /// result without panicking, even when the store has matching chunks.
    /// Pins the contract that downstream `BoundedScoreHeap::new(0)` and
    /// `semantic_limit = limit * 3 = 0` arithmetic don't blow up.
    #[test]
    fn test_search_filtered_limit_zero_returns_empty() {
        let (store, _dir) = setup_store();
        // Seed a chunk so the path actually traverses the cursor scan.
        let c = make_chunk("alpha", "src/a.rs", Language::Rust, ChunkType::Function);
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(&[(c, emb.clone())], Some(12345))
            .unwrap();

        let filter = SearchFilter::default();
        let results = store
            .search_filtered(&emb, &filter, 0, 0.0)
            .expect("limit=0 must not error");
        assert!(
            results.is_empty(),
            "limit=0 must return empty result, got {} entries",
            results.len()
        );
    }

    /// TC-ADV-V1.33-1: same contract for `search_filtered_with_index` —
    /// even with a vector index supplying candidate IDs, `limit=0` must
    /// short-circuit (or naturally produce empty) without panic.
    #[test]
    fn test_search_filtered_with_index_limit_zero_returns_empty() {
        use crate::embedder::Embedding;
        use crate::index::{IndexResult, VectorIndex};

        struct MockIdx {
            candidate_id: String,
        }
        impl VectorIndex for MockIdx {
            fn search(&self, _query: &Embedding, _k: usize) -> Vec<IndexResult> {
                vec![IndexResult {
                    id: self.candidate_id.clone(),
                    score: 0.9,
                }]
            }
            fn len(&self) -> usize {
                1
            }
            fn name(&self) -> &'static str {
                "MockIdx"
            }
            fn dim(&self) -> usize {
                crate::EMBEDDING_DIM
            }
        }

        let (store, _dir) = setup_store();
        let c = make_chunk("alpha", "src/a.rs", Language::Rust, ChunkType::Function);
        let chunk_id = c.id.clone();
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(&[(c, emb.clone())], Some(12345))
            .unwrap();

        let idx = MockIdx {
            candidate_id: chunk_id,
        };
        let filter = SearchFilter::default();
        let results = store
            .search_filtered_with_index(&emb, &filter, 0, 0.0, Some(&idx))
            .expect("limit=0 must not error");
        assert!(
            results.is_empty(),
            "limit=0 with index must return empty result, got {} entries",
            results.len()
        );
    }

    /// TC-7: Verify HNSW-guided path produces RRF results when enable_rrf is true.
    ///
    /// The search_by_candidate_ids path must apply the same RRF fusion as
    /// search_filtered, combining cosine-scored candidates with FTS keyword hits.
    #[test]
    fn test_search_by_candidate_ids_rrf() {
        let (store, _dir) = setup_store();

        // Insert chunks with content that FTS can match by keyword
        let mut c_error = make_chunk(
            "handleError",
            "src/err.rs",
            Language::Rust,
            ChunkType::Function,
        );
        c_error.content = "fn handleError() { log_error(\"error handling failed\"); }".to_string();
        let mut c_parse = make_chunk(
            "parseConfig",
            "src/cfg.rs",
            Language::Rust,
            ChunkType::Function,
        );
        c_parse.content = "fn parseConfig() { read_toml(\"config.toml\"); }".to_string();
        let emb1 = mock_embedding(1.0);
        let emb2 = mock_embedding(0.9);

        store
            .upsert_chunks_batch(
                &[(c_error.clone(), emb1.clone()), (c_parse.clone(), emb2)],
                Some(12345),
            )
            .unwrap();

        // Search by candidate IDs with RRF enabled — FTS should boost "handleError"
        // for the query text "error handling"
        let candidate_ids: Vec<&str> = vec![&c_error.id, &c_parse.id];
        let filter = SearchFilter {
            enable_rrf: true, // Test needs RRF on
            query_text: "error handling".to_string(),
            ..Default::default()
        };

        let results = store
            .search_by_candidate_ids(&candidate_ids, &emb1, &filter, 10, 0.0)
            .unwrap();

        assert!(
            !results.is_empty(),
            "RRF in candidate path should return results"
        );
        // "handleError" should rank first because it matches both semantically
        // and via FTS keyword "error"
        assert_eq!(
            results[0].chunk.name, "handleError",
            "FTS+RRF should boost the keyword-matching chunk"
        );

        // Compare with non-RRF path to verify RRF actually changes behavior
        let filter_no_rrf = SearchFilter {
            enable_rrf: false,
            query_text: "error handling".to_string(),
            ..Default::default()
        };
        let results_no_rrf = store
            .search_by_candidate_ids(&candidate_ids, &emb1, &filter_no_rrf, 10, 0.0)
            .unwrap();
        assert!(
            !results_no_rrf.is_empty(),
            "Non-RRF candidate path should also return results"
        );
    }

    #[test]
    fn test_search_filtered_respects_threshold() {
        let (store, _dir) = setup_store();

        let c1 = make_chunk("fn_a", "src/a.rs", Language::Rust, ChunkType::Function);
        let emb_opposite = mock_embedding(-1.0);
        store
            .upsert_chunks_batch(&[(c1, emb_opposite)], Some(12345))
            .unwrap();

        let query = mock_embedding(1.0);
        let filter = SearchFilter::default();
        let results = store.search_filtered(&query, &filter, 10, 0.99).unwrap();
        assert!(
            results.is_empty(),
            "Opposite embedding should not meet 0.99 threshold"
        );
    }

    #[test]
    fn test_search_filtered_respects_limit() {
        let (store, _dir) = setup_store();

        for i in 0..10 {
            let c = make_chunk(
                &format!("fn_{}", i),
                &format!("src/{}.rs", i),
                Language::Rust,
                ChunkType::Function,
            );
            let emb = mock_embedding(1.0 + i as f32 * 0.001);
            store.upsert_chunks_batch(&[(c, emb)], Some(12345)).unwrap();
        }

        let query = mock_embedding(1.0);
        let filter = SearchFilter::default();
        let results = store.search_filtered(&query, &filter, 3, 0.0).unwrap();
        assert_eq!(results.len(), 3);
    }

    // ===== candidate-count multiply overflow guards =====
    //
    // `search_hybrid` and `search_filtered_with_index` both compute the
    // dense-retrieval pool size via `cqs::limits::candidate_count_for`, which
    // applies a floor of 500 (env-overridable via `CQS_SEARCH_CANDIDATE_FLOOR`)
    // and a `saturating_mul` guard against `limit >= usize::MAX / 5` panics.

    /// Direct arithmetic regression for the saturating-multiply expression.
    /// Guards against a refactor that reverts to naive `limit * 5`.
    #[test]
    fn test_candidate_count_saturating_multiply() {
        use crate::limits::candidate_count_for;

        // usize::MAX / 4 would overflow `limit * 5`.
        let limit = usize::MAX / 4;
        let candidate_count = candidate_count_for(limit);

        // Saturating — no panic — and bounded at usize::MAX.
        assert_eq!(candidate_count, usize::MAX);

        // Small limit respects the floor of 500.
        let small = candidate_count_for(5);
        assert!(
            small >= 500,
            "candidate_count for limit=5 must hit the floor (>=500), got {small}"
        );

        // Typical limit scales linearly above the floor.
        let typical = candidate_count_for(200);
        assert_eq!(typical, 1000);
    }

    /// Regression for the CAGRA itopk_max cliff. When `candidate_count`
    /// exceeds the backend's `max_k`, the backend returns an empty Vec; in the
    /// SPLADE-fusion path with `α = 1.0` that collapses R@5 from ~0.66 to ~0.16
    /// on v3.v2 test queries when `CQS_SEARCH_CANDIDATE_FLOOR` crosses 441 (the
    /// cap for a 14k corpus). `cap_k_to_backend` trims `k` to the reported cap
    /// so the index returns its actual capacity instead.
    #[test]
    fn test_cap_k_to_backend_trims_to_max_k() {
        use crate::embedder::Embedding;
        use crate::index::{IndexResult, VectorIndex};

        struct CappedMock;
        impl VectorIndex for CappedMock {
            fn search(&self, _query: &Embedding, _k: usize) -> Vec<IndexResult> {
                vec![]
            }
            fn len(&self) -> usize {
                10_000
            }
            fn name(&self) -> &'static str {
                "CappedMock"
            }
            fn dim(&self) -> usize {
                crate::EMBEDDING_DIM
            }
            fn max_k(&self) -> Option<usize> {
                Some(441)
            }
        }
        struct UncappedMock;
        impl VectorIndex for UncappedMock {
            fn search(&self, _query: &Embedding, _k: usize) -> Vec<IndexResult> {
                vec![]
            }
            fn len(&self) -> usize {
                10_000
            }
            fn name(&self) -> &'static str {
                "UncappedMock"
            }
            fn dim(&self) -> usize {
                crate::EMBEDDING_DIM
            }
        }

        let capped: Box<dyn VectorIndex> = Box::new(CappedMock);
        let uncapped: Box<dyn VectorIndex> = Box::new(UncappedMock);

        // Capped backend: requests above cap get trimmed.
        assert_eq!(super::cap_k_to_backend(capped.as_ref(), 500), 441);
        assert_eq!(super::cap_k_to_backend(capped.as_ref(), 442), 441);
        // Below or at the cap: pass through unchanged.
        assert_eq!(super::cap_k_to_backend(capped.as_ref(), 441), 441);
        assert_eq!(super::cap_k_to_backend(capped.as_ref(), 100), 100);

        // Uncapped backend (HNSW shape): k always passes through.
        assert_eq!(super::cap_k_to_backend(uncapped.as_ref(), 500), 500);
        assert_eq!(
            super::cap_k_to_backend(uncapped.as_ref(), usize::MAX),
            usize::MAX
        );
    }

    /// Integration regression: exercise `search_filtered_with_index` with a
    /// mock index and a huge `limit` to prove the `limit * 5` call site is
    /// panic-free end-to-end. The mock index returns a known candidate id so
    /// the code path takes the `search_by_candidate_ids_with_notes` branch
    /// (where downstream `limit * 3` over-fetch does not apply).
    #[test]
    fn test_search_filtered_with_index_huge_limit_mock() {
        use crate::embedder::Embedding;
        use crate::index::{IndexResult, VectorIndex};

        struct MockIdx {
            candidate_id: String,
        }

        impl VectorIndex for MockIdx {
            fn search(&self, _query: &Embedding, _k: usize) -> Vec<IndexResult> {
                vec![IndexResult {
                    id: self.candidate_id.clone(),
                    score: 0.9,
                }]
            }
            fn len(&self) -> usize {
                1
            }
            fn name(&self) -> &'static str {
                "MockIdx"
            }
            fn dim(&self) -> usize {
                crate::EMBEDDING_DIM
            }
        }

        let (store, _dir) = setup_store();
        let c = make_chunk("big_fn", "src/big.rs", Language::Rust, ChunkType::Function);
        let chunk_id = c.id.clone();
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(&[(c, emb.clone())], Some(12345))
            .unwrap();

        let idx = MockIdx {
            candidate_id: chunk_id,
        };
        let filter = SearchFilter::default();

        // usize::MAX / 4 would overflow naive `limit * 5`.
        let huge = usize::MAX / 4;
        let results = store
            .search_filtered_with_index(&emb, &filter, huge, 0.0, Some(&idx))
            .expect("search must not panic on huge limit");

        // Only one chunk exists, so results are capped at 1.
        assert!(
            results.len() <= 1,
            "result count must be bounded by fixture, got {}",
            results.len()
        );
    }

    /// PERF wiring: when a backend reports `index_scores_are_cosine() == true`,
    /// `search_filtered_with_index` reuses the index-returned scores as the
    /// dense base instead of re-fetching each embedding and recomputing cosine.
    /// This pins that the reuse path and the recompute path produce identical
    /// scores AND identical ranking — the optimization must be score-neutral.
    ///
    /// Two mock indexes return the same candidates and the same per-candidate
    /// scores (the exact cosine, computed here from the stored embeddings); one
    /// reports cosine parity (reuse path) and one does not (recompute path).
    /// The store's recompute path independently derives cosine from the BLOB,
    /// so agreement proves the reused score equals the recomputed one.
    #[test]
    fn test_index_score_reuse_matches_recompute() {
        use crate::embedder::Embedding;
        use crate::index::{IndexResult, VectorIndex};

        // Backend that serves fixed (id, score) pairs and advertises whether
        // its scores are the brute-force cosine.
        struct ParityMock {
            results: Vec<IndexResult>,
            cosine: bool,
        }
        impl VectorIndex for ParityMock {
            fn search(&self, _query: &Embedding, k: usize) -> Vec<IndexResult> {
                self.results.iter().take(k).cloned().collect()
            }
            fn len(&self) -> usize {
                self.results.len()
            }
            fn name(&self) -> &'static str {
                "ParityMock"
            }
            fn dim(&self) -> usize {
                crate::EMBEDDING_DIM
            }
            fn index_scores_are_cosine(&self) -> bool {
                self.cosine
            }
        }

        // Build two distinct, non-collinear embeddings so their cosines to the
        // query differ — `mock_embedding` collapses every positive seed to the
        // same unit vector, which can't distinguish ranking.
        fn embedding_with_lead(lead: f32) -> Embedding {
            let mut v = vec![0.05f32; crate::EMBEDDING_DIM];
            v[0] = lead;
            let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
            for x in &mut v {
                *x /= norm;
            }
            Embedding::new(v)
        }

        let (store, _dir) = setup_store();
        let query = embedding_with_lead(1.0);
        let emb_a = embedding_with_lead(0.9);
        let emb_b = embedding_with_lead(0.3);

        let c_a = make_chunk("alpha", "src/a.rs", Language::Rust, ChunkType::Function);
        let c_b = make_chunk("beta", "src/b.rs", Language::Rust, ChunkType::Function);
        let id_a = c_a.id.clone();
        let id_b = c_b.id.clone();
        store
            .upsert_chunks_batch(&[(c_a, emb_a.clone()), (c_b, emb_b.clone())], Some(12345))
            .unwrap();

        // Exact cosine the index would have returned (HNSW DistCosine yields
        // `1 - dist = cos`). Same value the brute-force path recomputes.
        let cos_a = crate::math::cosine_similarity(query.as_slice(), emb_a.as_slice()).unwrap();
        let cos_b = crate::math::cosine_similarity(query.as_slice(), emb_b.as_slice()).unwrap();

        let results = vec![
            IndexResult {
                id: id_a.clone(),
                score: cos_a,
            },
            IndexResult {
                id: id_b.clone(),
                score: cos_b,
            },
        ];

        let reuse_idx = ParityMock {
            results: results.clone(),
            cosine: true,
        };
        let recompute_idx = ParityMock {
            results,
            cosine: false,
        };

        let filter = SearchFilter::default();
        let reuse = store
            .search_filtered_with_index(&query, &filter, 10, 0.0, Some(&reuse_idx))
            .expect("reuse path must succeed");
        let recompute = store
            .search_filtered_with_index(&query, &filter, 10, 0.0, Some(&recompute_idx))
            .expect("recompute path must succeed");

        assert_eq!(
            reuse.len(),
            recompute.len(),
            "reuse and recompute must return the same number of results"
        );
        // Ranking parity: identical id order.
        let reuse_ids: Vec<&str> = reuse.iter().map(|r| r.chunk.id.as_str()).collect();
        let recompute_ids: Vec<&str> = recompute.iter().map(|r| r.chunk.id.as_str()).collect();
        assert_eq!(
            reuse_ids, recompute_ids,
            "index-score reuse must not change ranking order"
        );
        // Score parity: each result's score matches within f32 epsilon.
        for (r, rc) in reuse.iter().zip(recompute.iter()) {
            assert!(
                (r.score - rc.score).abs() < 1e-6,
                "reused score {} must equal recomputed score {} for {}",
                r.score,
                rc.score,
                r.chunk.id
            );
        }
    }

    // ===== type_boost_factor() tests =====

    use std::sync::Mutex;
    /// Process-wide lock for env-touching tests. CQS_TYPE_BOOST is global
    /// state — parallel tests would race if they didn't serialize.
    static TYPE_BOOST_ENV_LOCK: Mutex<()> = Mutex::new(());

    /// Default fallback when env var is unset.
    #[test]
    fn test_type_boost_factor_default_when_unset() {
        let _guard = TYPE_BOOST_ENV_LOCK.lock().unwrap();
        std::env::remove_var("CQS_TYPE_BOOST");
        assert_eq!(type_boost_factor(), knob::knob("type_boost").default);
    }

    /// Valid float values are honored.
    #[test]
    fn test_type_boost_factor_valid_value() {
        let _guard = TYPE_BOOST_ENV_LOCK.lock().unwrap();
        for valid in &["1.0", "1.05", "1.5", "2.0", "0.5"] {
            std::env::set_var("CQS_TYPE_BOOST", valid);
            let parsed: f32 = valid.parse().unwrap();
            assert!(
                (type_boost_factor() - parsed).abs() < 1e-6,
                "CQS_TYPE_BOOST={valid} should produce {parsed}",
            );
        }
        std::env::remove_var("CQS_TYPE_BOOST");
    }

    /// Empty string env var is treated as a parse error → default fallback.
    /// (Bash gotcha: `export CQS_TYPE_BOOST=` shouldn't break the search.)
    #[test]
    fn test_type_boost_factor_empty_falls_back() {
        let _guard = TYPE_BOOST_ENV_LOCK.lock().unwrap();
        std::env::set_var("CQS_TYPE_BOOST", "");
        assert_eq!(type_boost_factor(), knob::knob("type_boost").default);
        std::env::remove_var("CQS_TYPE_BOOST");
    }

    /// Garbage values fall back to the default rather than poisoning scoring.
    #[test]
    fn test_type_boost_factor_invalid_falls_back() {
        let _guard = TYPE_BOOST_ENV_LOCK.lock().unwrap();
        for garbage in &["abc", "1.2x", "true", "--", "1.0e", "1,2"] {
            std::env::set_var("CQS_TYPE_BOOST", garbage);
            assert_eq!(
                type_boost_factor(),
                knob::knob("type_boost").default,
                "CQS_TYPE_BOOST={garbage:?} should fall back to default",
            );
        }
        std::env::remove_var("CQS_TYPE_BOOST");
    }

    /// Non-positive and non-finite values must NOT silently zero out scores.
    /// This is the load-bearing safety property — without it, a typo'd
    /// env var like `CQS_TYPE_BOOST=0` would multiply matching chunks
    /// to score 0 and effectively *exclude* them, which would silently
    /// destroy recall.
    #[test]
    fn test_type_boost_factor_rejects_zero_negative_nan_inf() {
        let _guard = TYPE_BOOST_ENV_LOCK.lock().unwrap();
        for unsafe_val in &["0", "0.0", "-1.0", "-0.5", "NaN", "nan", "inf", "-inf"] {
            std::env::set_var("CQS_TYPE_BOOST", unsafe_val);
            assert_eq!(
                type_boost_factor(),
                knob::knob("type_boost").default,
                "CQS_TYPE_BOOST={unsafe_val:?} must be rejected — \
                 a non-positive or non-finite boost would corrupt scoring",
            );
        }
        std::env::remove_var("CQS_TYPE_BOOST");
    }

    /// The function re-reads the env var on every call (no caching) so a
    /// process can vary the boost across calls. Critical for tests but
    /// also for any future code that wants to use the boost factor in
    /// a long-running process.
    #[test]
    fn test_type_boost_factor_reads_env_on_each_call() {
        let _guard = TYPE_BOOST_ENV_LOCK.lock().unwrap();
        std::env::set_var("CQS_TYPE_BOOST", "1.3");
        let first = type_boost_factor();
        std::env::set_var("CQS_TYPE_BOOST", "1.7");
        let second = type_boost_factor();
        std::env::remove_var("CQS_TYPE_BOOST");
        assert!((first - 1.3).abs() < 1e-6);
        assert!((second - 1.7).abs() < 1e-6);
    }

    // ===== note-boost scoring relocation pins =====
    //
    // These pin the note-boost scoring path end-to-end so the cache
    // relocation (boost cache moving out of `store/` into `search/`) stays
    // bit-identical. They assert exact post-boost scores for boosted,
    // demoted, and neutral mentions, and pin the cache-invalidation contract
    // (a note change refreshes the boost data on the next search).

    /// Seed a notes file and upsert one note with the given sentiment +
    /// mention, returning nothing — the store now carries the note.
    #[cfg(test)]
    fn seed_note(
        store: &crate::store::Store,
        dir: &std::path::Path,
        sentiment: f32,
        mention: &str,
    ) {
        let source = dir.join("notes.toml");
        std::fs::write(&source, "# pin").unwrap();
        let note = crate::note::Note {
            id: "note:0".to_string(),
            text: "pin note".to_string(),
            sentiment,
            mentions: vec![mention.to_string()],
            kind: None,
        };
        store.replace_notes_for_file(&[note], &source, 1).unwrap();
    }

    /// Bit-identical note-boost scoring through `search_filtered`.
    ///
    /// A name-mention note with positive sentiment must multiply the matched
    /// chunk's cosine by exactly `1.0 + sentiment * note_boost_factor`; a
    /// negative-sentiment note must demote by the same formula; an unrelated
    /// (neutral) chunk must be left at its raw cosine. The base cosine here is
    /// 1.0 (query == chunk embedding via `mock_embedding(1.0)`), so the boosted
    /// score equals the multiplier exactly.
    #[test]
    fn pin_note_boost_scoring_boosted_demoted_neutral() {
        use crate::search::scoring::ScoringConfig;
        let factor = ScoringConfig::current().note_boost_factor;

        // --- boosted: positive sentiment on a name mention ---
        let (store, dir) = setup_store();
        let boosted = make_chunk(
            "boostedFn",
            "src/boosted.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let neutral = make_chunk(
            "neutralFn",
            "src/neutral.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(
                &[
                    (boosted.clone(), emb.clone()),
                    (neutral.clone(), emb.clone()),
                ],
                Some(1),
            )
            .unwrap();
        seed_note(&store, dir.path(), 0.5, "boostedFn");

        let filter = SearchFilter::default();
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        let boosted_score = results
            .iter()
            .find(|r| r.chunk.name == "boostedFn")
            .expect("boosted chunk present")
            .score;
        let neutral_score = results
            .iter()
            .find(|r| r.chunk.name == "neutralFn")
            .expect("neutral chunk present")
            .score;
        // Base cosine is 1.0; boost multiplies it.
        let expected_boost = 1.0 + 0.5 * factor;
        assert!(
            (boosted_score - expected_boost).abs() < 1e-6,
            "boosted score {boosted_score} must equal 1.0 * (1.0 + 0.5*{factor}) = {expected_boost}"
        );
        assert!(
            (neutral_score - 1.0).abs() < 1e-6,
            "neutral (unmentioned) chunk must keep raw cosine 1.0, got {neutral_score}"
        );

        // --- demoted: negative sentiment on a name mention ---
        let (store, dir) = setup_store();
        let demoted = make_chunk(
            "demotedFn",
            "src/demoted.rs",
            Language::Rust,
            ChunkType::Function,
        );
        store
            .upsert_chunks_batch(&[(demoted.clone(), emb.clone())], Some(1))
            .unwrap();
        seed_note(&store, dir.path(), -1.0, "demotedFn");
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        let demoted_score = results
            .iter()
            .find(|r| r.chunk.name == "demotedFn")
            .expect("demoted chunk present")
            .score;
        let expected_demote = 1.0 - factor;
        assert!(
            (demoted_score - expected_demote).abs() < 1e-6,
            "demoted score {demoted_score} must equal 1.0 * (1.0 - 1.0*{factor}) = {expected_demote}"
        );
    }

    /// Cache-invalidation contract: a note change must be reflected by the
    /// next `search_filtered` call. Seed a positive note, observe the boost,
    /// replace it with a stronger negative note, and confirm the score flips
    /// to the new multiplier — proving the boost cache refreshed alongside the
    /// notes cache rather than serving stale boost data.
    #[test]
    fn pin_note_boost_cache_refreshes_on_note_change() {
        use crate::search::scoring::ScoringConfig;
        let factor = ScoringConfig::current().note_boost_factor;

        let (store, dir) = setup_store();
        let chunk = make_chunk(
            "targetFn",
            "src/target.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(&[(chunk.clone(), emb.clone())], Some(1))
            .unwrap();
        let filter = SearchFilter::default();

        // First note: positive boost. Populates the boost cache.
        seed_note(&store, dir.path(), 0.5, "targetFn");
        let first = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        let first_score = first
            .iter()
            .find(|r| r.chunk.name == "targetFn")
            .expect("target present")
            .score;
        assert!(
            (first_score - (1.0 + 0.5 * factor)).abs() < 1e-6,
            "first search must apply +0.5 boost, got {first_score}"
        );

        // Replace with a negative note. `replace_notes_for_file` invalidates
        // the notes cache; the boost cache must refresh from it.
        seed_note(&store, dir.path(), -1.0, "targetFn");
        let second = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        let second_score = second
            .iter()
            .find(|r| r.chunk.name == "targetFn")
            .expect("target present")
            .score;
        assert!(
            (second_score - (1.0 - factor)).abs() < 1e-6,
            "after note change the boost cache must refresh to the -1.0 demote, got {second_score}"
        );
    }

    /// HARD GATE: ranking-provenance recording must be bit-identical to not
    /// recording. Run the same search twice — once with
    /// `record_rank_signals = false`, once `true` — over a corpus that
    /// exercises a note boost, a test-function demotion, and an RRF fusion
    /// path, and assert the per-result `(id, score)` sequence is byte-for-byte
    /// equal. The only permitted difference is the populated `rank_signals`
    /// field on the recording run. Mirrors the `apply_scoring_pipeline_pinned_
    /// exact_scores` pre/post pattern at the full-search level.
    #[test]
    fn pin_rank_signals_recording_is_bit_identical() {
        let (store, dir) = setup_store();
        let c_target = make_chunk(
            "handleError",
            "src/err.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let c_test = make_chunk(
            "test_handleError",
            "src/err_test.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let c_plain = make_chunk(
            "parseInput",
            "src/in.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(
                &[
                    (c_target.clone(), emb.clone()),
                    (c_test.clone(), emb.clone()),
                    (c_plain.clone(), emb.clone()),
                ],
                Some(1),
            )
            .unwrap();
        // A note boost on handleError so the recording run has a discriminative
        // signal to carry.
        seed_note(&store, dir.path(), 0.5, "handleError");

        let off = SearchFilter {
            enable_rrf: true,
            query_text: "error handling".to_string(),
            record_rank_signals: false,
            ..Default::default()
        };
        let on = SearchFilter {
            enable_rrf: true,
            query_text: "error handling".to_string(),
            record_rank_signals: true,
            ..Default::default()
        };

        let res_off = store.search_filtered(&emb, &off, 10, 0.0).unwrap();
        let res_on = store.search_filtered(&emb, &on, 10, 0.0).unwrap();

        // Same length, same order, bit-identical scores (exact f32 equality).
        assert_eq!(res_off.len(), res_on.len(), "result count changed");
        for (a, b) in res_off.iter().zip(res_on.iter()) {
            assert_eq!(a.chunk.id, b.chunk.id, "result order changed");
            assert_eq!(
                a.score.to_bits(),
                b.score.to_bits(),
                "score bits changed for {} (off={}, on={})",
                a.chunk.id,
                a.score,
                b.score
            );
        }

        // The off run records nothing; the on run carries a note_boost on the
        // mentioned chunk.
        assert!(
            res_off.iter().all(|r| r.rank_signals.is_empty()),
            "recording-off run must carry no rank_signals"
        );
        let target = res_on
            .iter()
            .find(|r| r.chunk.name == "handleError")
            .expect("target present");
        assert!(
            target
                .rank_signals
                .iter()
                .any(|s| s.signal == "note_boost" && s.value > 1.0),
            "recording-on run must carry note_boost on the mentioned chunk; got {:?}",
            target.rank_signals
        );
    }

    /// A test-function chunk picks up the `importance` demotion signal (and the
    /// `dense` rank context) when recording is on. Pins the signal vocabulary
    /// and skip-when-empty behaviour for a non-note signal.
    #[test]
    fn rank_signals_records_importance_for_test_chunk() {
        let (store, _dir) = setup_store();
        let prod = make_chunk("realFn", "src/real.rs", Language::Rust, ChunkType::Function);
        let test = make_chunk(
            "test_realFn",
            "src/real_test.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(
                &[(prod.clone(), emb.clone()), (test.clone(), emb.clone())],
                Some(1),
            )
            .unwrap();

        let filter = SearchFilter {
            record_rank_signals: true,
            ..SearchFilter::default()
        };
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();

        let test_res = results
            .iter()
            .find(|r| r.chunk.name == "test_realFn")
            .expect("test chunk present");
        assert!(
            test_res
                .rank_signals
                .iter()
                .any(|s| s.signal == "importance" && s.value < 1.0),
            "demoted test chunk must record importance < 1.0; got {:?}",
            test_res.rank_signals
        );
        // The production chunk has no discriminative signal → empty (the
        // skip-when-empty case, zero wire overhead).
        let prod_res = results
            .iter()
            .find(|r| r.chunk.name == "realFn")
            .expect("prod chunk present");
        assert!(
            prod_res.rank_signals.is_empty(),
            "unboosted production chunk must record nothing; got {:?}",
            prod_res.rank_signals
        );
    }

    /// `rank_signals` is an honest mirror of the scoring that actually ran: the
    /// `note_boost` entry appears iff a note moved the score. With no notes
    /// seeded the boost is 1.0, so no `note_boost` is recorded — the
    /// discriminator here is note presence. (Audit-mode suppression, which
    /// forces the multiplier to 1.0 even with notes present and omits the
    /// signal, is pinned separately by `pin_audit_mode_suppresses_note_boost`.)
    #[test]
    fn rank_signals_note_boost_tracks_actual_scoring() {
        let (store, _dir) = setup_store();
        let chunk = make_chunk(
            "lonelyFn",
            "src/lonely.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(&[(chunk.clone(), emb.clone())], Some(1))
            .unwrap();

        // No note seeded → the note boost is 1.0 → no `note_boost` signal, and
        // with no other discriminative signal the array is empty.
        let filter = SearchFilter {
            record_rank_signals: true,
            ..SearchFilter::default()
        };
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        let res = results
            .iter()
            .find(|r| r.chunk.name == "lonelyFn")
            .expect("chunk present");
        assert!(
            !res.rank_signals.iter().any(|s| s.signal == "note_boost"),
            "no note seeded → no note_boost signal; got {:?}",
            res.rank_signals
        );
    }

    /// discriminative-by-default on the `--rrf` path. With several
    /// chunks that all match the keyword leg, "appeared in FTS" is the norm, so
    /// the majority of results must carry NO `rank_signals` — `fts` records only
    /// when its leg rank materially leads the dense rank. Before the fix every
    /// fused result carried an `fts` signal (cry-wolf); after it, the typical
    /// result is silent.
    #[test]
    fn rrf_discriminative_by_default_majority_silent() {
        let (store, _dir) = setup_store();
        // Eight chunks whose names all share the "handler" keyword so every one
        // appears in the FTS leg for the query below. No notes, no demotion
        // targets, no type boost — the only candidate signal is `fts`.
        let names = [
            "alphaHandler",
            "betaHandler",
            "gammaHandler",
            "deltaHandler",
            "epsilonHandler",
            "zetaHandler",
            "etaHandler",
            "thetaHandler",
        ];
        let emb = mock_embedding(1.0);
        let chunks: Vec<_> = names
            .iter()
            .enumerate()
            .map(|(i, n)| {
                (
                    make_chunk(
                        n,
                        &format!("src/h{i}.rs"),
                        Language::Rust,
                        ChunkType::Function,
                    ),
                    emb.clone(),
                )
            })
            .collect();
        store.upsert_chunks_batch(&chunks, Some(1)).unwrap();

        let filter = SearchFilter {
            enable_rrf: true,
            query_text: "handler".to_string(),
            record_rank_signals: true,
            ..SearchFilter::default()
        };
        let results = store.search_filtered(&emb, &filter, 10, 0.0).unwrap();
        assert!(results.len() >= 5, "expected a populated result set");

        let with_signals = results
            .iter()
            .filter(|r| !r.rank_signals.is_empty())
            .count();
        let fraction = with_signals as f32 / results.len() as f32;
        assert!(
            fraction < 0.5,
            "discriminative-by-default: the majority of RRF results must carry no \
             signals, but {with_signals}/{} did ({fraction:.2}); signals: {:?}",
            results.len(),
            results
                .iter()
                .map(|r| (&r.chunk.name, &r.rank_signals))
                .collect::<Vec<_>>()
        );
    }

    /// HARD GATE: `suppress_note_boost` (the audit-mode signal) makes
    /// note sentiment genuinely stop moving code rankings. Three runs over the
    /// same corpus, with a positive note seeded on the target chunk:
    ///   (a) notes live, suppression off  — note_boost moves the score;
    ///   (b) suppression on               — boost-free scoring;
    ///   (c) no notes at all              — the boost-free baseline.
    /// Assert (b) == (c) bit-for-bit (suppressed == notes-free), (a) != (b)
    /// (the note actually mattered), and that the suppressed run records NO
    /// `note_boost` signal even with the note present.
    #[test]
    fn pin_audit_mode_suppresses_note_boost() {
        let (store, dir) = setup_store();
        let target = make_chunk(
            "auditTarget",
            "src/audit_target.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let other = make_chunk(
            "neighbor",
            "src/neighbor.rs",
            Language::Rust,
            ChunkType::Function,
        );
        let emb = mock_embedding(1.0);
        store
            .upsert_chunks_batch(
                &[(target.clone(), emb.clone()), (other.clone(), emb.clone())],
                Some(1),
            )
            .unwrap();

        // Baseline (c): score the corpus with no notes at all.
        let plain = SearchFilter {
            record_rank_signals: true,
            ..SearchFilter::default()
        };
        let res_noteless = store.search_filtered(&emb, &plain, 10, 0.0).unwrap();
        let noteless_target = res_noteless
            .iter()
            .find(|r| r.chunk.name == "auditTarget")
            .expect("target present")
            .score;

        // Seed a strong positive note on the target.
        seed_note(&store, dir.path(), 1.0, "auditTarget");

        // (a) notes live, suppression off — the boost moves the score.
        let live = SearchFilter {
            record_rank_signals: true,
            suppress_note_boost: false,
            ..SearchFilter::default()
        };
        let res_live = store.search_filtered(&emb, &live, 10, 0.0).unwrap();
        let live_target = res_live
            .iter()
            .find(|r| r.chunk.name == "auditTarget")
            .expect("target present")
            .score;
        assert!(
            live_target.to_bits() != noteless_target.to_bits(),
            "control: a positive note must move the score off the boost-free baseline \
             (live={live_target}, baseline={noteless_target})"
        );

        // (b) suppression on — note present but boost forced to 1.0.
        let suppressed = SearchFilter {
            record_rank_signals: true,
            suppress_note_boost: true,
            ..SearchFilter::default()
        };
        let res_suppressed = store.search_filtered(&emb, &suppressed, 10, 0.0).unwrap();

        // (b) == (c): the suppressed scores are bit-identical to the notes-free
        // baseline, position for position.
        assert_eq!(
            res_suppressed.len(),
            res_noteless.len(),
            "result count diverged under suppression"
        );
        for (s, n) in res_suppressed.iter().zip(res_noteless.iter()) {
            assert_eq!(s.chunk.id, n.chunk.id, "order diverged under suppression");
            assert_eq!(
                s.score.to_bits(),
                n.score.to_bits(),
                "suppressed score must equal the notes-free baseline for {} \
                 (suppressed={}, baseline={})",
                s.chunk.id,
                s.score,
                n.score
            );
        }

        // No `note_boost` signal anywhere on the suppressed run, even though a
        // note is present in the store.
        assert!(
            res_suppressed
                .iter()
                .all(|r| !r.rank_signals.iter().any(|s| s.signal == "note_boost")),
            "audit-mode suppression must omit every note_boost signal; got {:?}",
            res_suppressed
                .iter()
                .map(|r| &r.rank_signals)
                .collect::<Vec<_>>()
        );
    }
}
