//! Profile executor: scores and ranks candidates according to a `RankingProfile`.
//!
//! The executor implements the per-candidate scoring stages of the ranking
//! pipeline (spec 09-ranking-scoring.md):
//! 1. **Exclude**: hard-remove candidates matching any `Exclude` rule (Stage 2)
//! 2. **Gate**: hard-remove candidates failing any gate threshold (Stage 6)
//! 3. **Score**: compose each candidate's score from the `Sort` mode, an optional
//!    fused-relevance seed, boosts (Stage 4), the co-engagement blend, penalties
//!    (Stage 5), and the optional `ProfileDecay` recency factor
//! 4. **Sort**: order candidates by descending score
//! 5. **Normalize**: min-max normalize scores to `[0.0, 1.0]`
//!
//! Diversity enforcement (Stage 8) is deliberately *not* performed here: the
//! executor does not load the creator/format metadata it requires, so it runs
//! later in the query pipeline where that metadata is available.

pub mod context;
pub mod formulas;
pub mod helpers;

// Re-export all public items so that `crate::ranking::executor::Foo` paths continue to work.
use std::collections::HashMap;

pub use context::{ScoredCandidate, UserContext};
use formulas::{INTERACTION_BOOST_WEIGHT, PREFERENCE_BOOST_WEIGHT, RELEVANCE_BASE_WEIGHT};
use helpers::{normalize, passes_excludes, passes_gates, read_agg_for_sort};

use super::profile::{RankingProfile, SignalAgg};
use crate::{
    entities::{UserSignalIndex, UserStateIndex},
    load::DegradationLevel,
    schema::{EntityId, Timestamp, Window},
    session::SessionContext,
    signals::SignalLedger,
};

/// Neutralize a `NaN` score at *construction*, before it can reach the sort.
///
/// Boost arithmetic (additive session / interaction / co-engagement terms over
/// ledger reads) can produce a `NaN` -- e.g. `0.0 * Inf` or `Inf - Inf`. A
/// `NaN` has no defined order, so leaving it in the candidate vector scrambles
/// the descending sort (the original `partial_cmp(...).unwrap_or(Equal)`
/// silently treated every `NaN` comparison as "equal", corrupting the ranking).
/// Mapping `NaN` to `0.0` here -- the same neutral value [`normalize`] floors
/// `NaN` to -- removes it before sorting.
///
/// Infinities are intentionally preserved: several sort modes emit
/// `f64::NEG_INFINITY` as a deliberate "sort last" sentinel for missing
/// metadata (`Shortest`, `Longest`, `DateSaved`), and `f64::total_cmp` (used
/// for the sort below) is a true total order over `[-Inf, +Inf]`, so an
/// infinite score ranks correctly without any clamp. [`normalize`] then folds
/// the surviving sentinels to the matching *end* of the `[0, 1]` output range
/// (`-Inf -> 0.0`, `+Inf -> 1.0`), computing the min/max range over the finite
/// scores only so a missing-metadata item stays monotonic with its rank rather
/// than reporting the maximum score (see [`normalize`]).
#[inline]
const fn finite_score(raw: f64) -> f64 {
    if raw.is_nan() { 0.0 } else { raw }
}

// -- Executor -----------------------------------------------------------------

/// Scores and ranks candidates according to a `RankingProfile`.
///
/// Holds a reference to the `SignalLedger` for reading decay scores, windowed
/// counts, and velocities. The executor itself is stateless -- all configuration
/// comes from the profile passed to `score()`.
pub struct ProfileExecutor<'a> {
    ledger: &'a SignalLedger,
    /// M6: co-engagement index for `related` profile scoring.
    co_engagement: Option<&'a crate::entities::CoEngagementIndex>,
    /// M6: seed item for co-engagement scoring (used with `similar_to`).
    seed_item: Option<EntityId>,
    /// M6: per-user signal index for social-graph-scoped trending.
    user_signal_index: Option<&'a UserSignalIndex>,
    /// M6: resolved social subgraph user IDs for trending scope.
    social_graph_users: Option<Vec<u64>>,
    /// M6p3: item metadata for metadata-based sorts (alphabetical, duration).
    item_metadata: Option<&'a HashMap<u64, HashMap<String, String>>>,
    /// M6p3: user state + user ID for `DateSaved` sort.
    user_state_for_date_saved: Option<(&'a UserStateIndex, u64)>,
    /// M7p2: degradation level for coarse-aggregate window substitution.
    degradation_level: DegradationLevel,
    /// User identity for the `Shuffle` sort's per-user, per-minute seed (spec
    /// §11.6: `seed = hash(user_id, timestamp_minute)`). `0` is the anonymous /
    /// no-`FOR USER` case, which yields a stable per-minute *global* permutation
    /// rather than per-user variety. Set via [`Self::with_shuffle_user`].
    shuffle_user_id: u64,
}

impl<'a> ProfileExecutor<'a> {
    #[must_use]
    pub const fn new(ledger: &'a SignalLedger) -> Self {
        Self {
            ledger,
            co_engagement: None,
            seed_item: None,
            user_signal_index: None,
            social_graph_users: None,
            item_metadata: None,
            user_state_for_date_saved: None,
            degradation_level: DegradationLevel::Full,
            shuffle_user_id: 0,
        }
    }

    /// Set the user identity for the `Shuffle` sort's per-user, per-minute seed.
    ///
    /// Spec §11.6 seeds shuffle with `hash(user_id, timestamp_minute)` so a
    /// "surprise me" surface refreshes per minute and varies per user. The query
    /// pipeline calls this with the resolved `FOR USER` id when present; without a
    /// user it stays `0` (a stable per-minute global permutation). Only `Shuffle`
    /// reads this field; every other sort ignores it.
    #[must_use]
    pub(crate) const fn with_shuffle_user(mut self, user_id: u64) -> Self {
        self.shuffle_user_id = user_id;
        self
    }

    /// Set the degradation level for coarse-aggregate window substitution.
    ///
    /// Under `CoarseAggregates`, `SignalAgg::Value` reads use `Window::AllTime`
    /// and `SignalAgg::Velocity` reads use `Window::TwentyFourHours`, reducing
    /// the number of fine-grained bucket scans during high-load scoring.
    #[must_use]
    pub(crate) const fn with_degradation_level(mut self, level: DegradationLevel) -> Self {
        self.degradation_level = level;
        self
    }

    /// Attach a co-engagement index for `related` profile scoring.
    #[must_use]
    pub const fn with_co_engagement(
        mut self,
        index: &'a crate::entities::CoEngagementIndex,
    ) -> Self {
        self.co_engagement = Some(index);
        self
    }

    /// Set the seed item for co-engagement scoring (from `similar_to`).
    #[must_use]
    pub const fn with_seed(mut self, seed: EntityId) -> Self {
        self.seed_item = Some(seed);
        self
    }

    /// Attach a per-user signal index for social-graph-scoped trending.
    #[must_use]
    pub const fn with_user_signal_index(mut self, index: &'a UserSignalIndex) -> Self {
        self.user_signal_index = Some(index);
        self
    }

    /// Set the resolved social subgraph user IDs for trending scope.
    ///
    /// When set together with `user_signal_index`, `score_trending` uses
    /// aggregate velocity across these users instead of the global ledger.
    #[must_use]
    pub fn with_social_graph_users(mut self, users: Vec<u64>) -> Self {
        self.social_graph_users = Some(users);
        self
    }

    /// Attach item metadata for metadata-based sorts (alphabetical, duration).
    #[must_use]
    pub const fn with_item_metadata(
        mut self,
        metadata: &'a HashMap<u64, HashMap<String, String>>,
    ) -> Self {
        self.item_metadata = Some(metadata);
        self
    }

    /// Attach user state context for `DateSaved` sort.
    #[must_use]
    pub const fn with_date_saved_context(
        mut self,
        user_state: &'a UserStateIndex,
        user_id: u64,
    ) -> Self {
        self.user_state_for_date_saved = Some((user_state, user_id));
        self
    }

    /// Score and rank a set of candidate entities according to the given profile.
    ///
    /// Executes the per-candidate pipeline: exclude -> gate -> score -> sort ->
    /// normalize. Diversity (Stage 8) is applied later in the query pipeline, not
    /// here (see [`Self::finalize`]).
    ///
    /// - `candidates`: entity IDs to consider; excluded/gate-failing candidates are removed.
    /// - `profile`: the ranking profile controlling sort mode, boosts, penalties, gates, excludes, and decay.
    /// - `now`: the query's logical timestamp. **Clock contract:** `now` is the
    ///   single clock for the whole query. It is converted to `now_ns` once and
    ///   threaded into every time-dependent ledger read -- windowed counts,
    ///   velocities, `SignalAgg::DecayScore`, the `ProfileDecay` recency factor,
    ///   and the gate/exclude evaluations -- via the ledger's explicit-clock `_at`
    ///   variants. Scoring is therefore a pure function of `(ledger snapshot,
    ///   now)`: re-running the identical query against an unchanged ledger yields
    ///   byte-identical scores (Accuracy-W, M0-M10 review). The one remaining
    ///   `now`-independent piece is `Hot`'s per-candidate *age*, which uses a
    ///   uniform 24h term until a per-entity `created_at` reverse map is plumbed
    ///   into the executor.
    ///
    /// Returns candidates sorted by descending normalized score in `[0.0, 1.0]`.
    ///
    /// # Errors
    ///
    /// Propagates any exclude/gate/scoring error (unknown signal / unsupported
    /// aggregation) so a misconfigured profile fails the query loudly.
    pub fn score(
        &self,
        candidates: &[EntityId],
        profile: &RankingProfile,
        now: Timestamp,
    ) -> crate::Result<Vec<ScoredCandidate>> {
        self.score_inner(candidates, profile, now, None, &HashMap::new(), None)
    }

    /// Score and rank candidates with an optional FOR SESSION boost applied.
    ///
    /// When `session_ctx` is `Some`, applies an additive boost per candidate based
    /// on keyword hint matching: keywords from session annotations are matched against
    /// each item's metadata values.
    ///
    /// The session boost is applied after base scoring and before normalization.
    ///
    /// `retrieval_scores`, when `Some`, maps `entity_id -> normalized fused/RRF
    /// relevance score in [0, 1]` from SEARCH Stage 1c; it seeds each candidate's
    /// base so the fused relevance order anchors the ranking and the profile's
    /// boosts act as a quality overlay (see [`RELEVANCE_BASE_WEIGHT`]). Pass
    /// `None` for non-relevance-seeded paths (RETRIEVE / browse).
    ///
    /// # Errors
    ///
    /// Propagates any exclude/gate/scoring error (unknown signal / unsupported
    /// aggregation) so a misconfigured profile fails the query loudly.
    pub fn score_with_session(
        &self,
        candidates: &[EntityId],
        profile: &RankingProfile,
        now: Timestamp,
        session_ctx: Option<&SessionContext>,
        item_metadata: &HashMap<u64, HashMap<String, String>>,
        retrieval_scores: Option<&HashMap<u64, f64>>,
    ) -> crate::Result<Vec<ScoredCandidate>> {
        self.score_inner(
            candidates,
            profile,
            now,
            session_ctx,
            item_metadata,
            retrieval_scores,
        )
    }

    /// Score and rank candidates with user-specific personalization.
    ///
    /// In addition to the base scoring and optional session boost, this method
    /// applies an additive creator-interaction boost: items from creators the
    /// user has interacted with receive a positive boost proportional to the
    /// interaction weight times `INTERACTION_BOOST_WEIGHT`.
    ///
    /// The interaction boost is applied after base scoring and session boost,
    /// but before normalization, so it participates in the min-max range.
    ///
    /// `retrieval_scores`, when `Some`, seeds each candidate's base with its
    /// normalized fused/RRF relevance score (SEARCH path); pass `None` for the
    /// RETRIEVE path. See [`RELEVANCE_BASE_WEIGHT`] for how the seed interacts
    /// with profile boosts.
    ///
    /// # Errors
    ///
    /// Propagates any exclude/gate/scoring error (unknown signal / unsupported
    /// aggregation) so a misconfigured profile fails the query loudly.
    // Each parameter is a distinct scoring input the RETRIEVE/SEARCH callers
    // supply individually (candidates, profile, clock, session, user, metadata,
    // optional relevance seed); bundling them into a struct would only relocate
    // the same fields without reducing real complexity.
    #[allow(clippy::too_many_arguments)]
    pub fn score_personalized(
        &self,
        candidates: &[EntityId],
        profile: &RankingProfile,
        now: Timestamp,
        session_ctx: Option<&SessionContext>,
        user_ctx: &UserContext,
        item_metadata: &HashMap<u64, HashMap<String, String>>,
        retrieval_scores: Option<&HashMap<u64, f64>>,
    ) -> crate::Result<Vec<ScoredCandidate>> {
        self.score_candidates(
            candidates,
            profile,
            now,
            session_ctx,
            item_metadata,
            retrieval_scores,
            |entity_id, snapshot| {
                // Creator-interaction boost: look up the per-item boost from
                // the pre-computed map. Items from highly-interacted creators
                // get a positive additive boost before normalization. This map is
                // sourced from a `RoaringBitmap` (inherently u32-keyed), so it is
                // looked up by the narrowed `u32` slot, matching how the producer
                // populated it from the bitmap's u32 entries.
                let item_id = crate::entities::user_state::narrow_item_slot(entity_id.as_u64());
                let interaction_boost = user_ctx
                    .creator_interaction_boosts
                    .get(&item_id)
                    .map_or(0.0, |&weight| weight * INTERACTION_BOOST_WEIGHT);

                // Preference-vector boost: cosine similarity between the user's
                // preference vector and this candidate's content embedding,
                // pre-computed by the retrieve executor. Items closer to the user's
                // learned taste get a positive additive boost; dissimilar items
                // (negative cosine) are mildly penalized. Folds into the same
                // pre-normalization additive range as the other boosts. Keyed by
                // the FULL u64 id so two candidates differing only above bit 32
                // never alias onto one slot and cross-contaminate their boosts
                // (query-executor SUGGESTION, M0-M10 review pass 2).
                let mut preference_boost = 0.0;
                if let Some(&cosine) = user_ctx.preference_boosts.get(&entity_id.as_u64()) {
                    preference_boost = cosine * PREFERENCE_BOOST_WEIGHT;
                    if preference_boost != 0.0 {
                        snapshot.push(("preference_affinity".to_string(), preference_boost));
                    }
                }
                interaction_boost + preference_boost
            },
        )
    }

    /// Shared scoring pipeline: exclude -> gate -> score -> sort -> normalize.
    ///
    /// Hard exclusion (Stage 2) and gates (Stage 6) remove candidates up front
    /// via [`passes_excludes`] / [`passes_gates`]; scoring (boosts, penalties,
    /// decay, optional relevance seed) happens in [`Self::compute_raw_score`].
    /// Diversity is applied later in the query pipeline, not here (see
    /// [`Self::finalize`]).
    ///
    /// When `session_ctx` is `Some`, an additive session boost is applied to each
    /// candidate's raw score before normalization. `item_metadata` provides the
    /// metadata for keyword hint matching; pass an empty map when not needed.
    /// `retrieval_scores`, when `Some`, seeds each candidate's base with its
    /// normalized fused/RRF relevance score (SEARCH path); pass `None` otherwise.
    ///
    /// # Errors
    ///
    /// Propagates any exclude/gate/scoring error (unknown signal / unsupported
    /// aggregation) so a misconfigured profile fails the query loudly.
    fn score_inner(
        &self,
        candidates: &[EntityId],
        profile: &RankingProfile,
        now: Timestamp,
        session_ctx: Option<&SessionContext>,
        item_metadata: &HashMap<u64, HashMap<String, String>>,
        retrieval_scores: Option<&HashMap<u64, f64>>,
    ) -> crate::Result<Vec<ScoredCandidate>> {
        // No personalization overlay on this path: base + session boost only.
        self.score_candidates(
            candidates,
            profile,
            now,
            session_ctx,
            item_metadata,
            retrieval_scores,
            |_entity_id, _snapshot| 0.0,
        )
    }

    /// Shared candidate-scoring loop behind [`Self::score_inner`] and
    /// [`Self::score_personalized`].
    ///
    /// Both entry points share the identical skeleton -- lower the session keywords
    /// once, then for each candidate run exclude/gate filtering, compute the raw
    /// score + session boost, push a [`ScoredCandidate`], and finally sort +
    /// normalize. They differ only in an optional per-candidate *additive overlay*
    /// (the personalized path adds creator-interaction and preference-vector
    /// boosts and records the latter in the snapshot). `extra_boost` supplies that
    /// overlay: it returns the additional additive contribution and may push
    /// explain-ability entries onto the candidate's snapshot; the base path passes
    /// a closure that returns `0.0`. Extracting the loop removes the duplicated
    /// skeleton flagged in the M0-M10 review (DRY-S) while keeping the two public
    /// behaviors byte-for-byte identical.
    ///
    /// # Errors
    ///
    /// Propagates any exclude/gate/scoring error (unknown signal / unsupported
    /// aggregation) so a misconfigured profile fails the query loudly.
    // Mirrors `score_personalized`'s argument list (the same per-candidate scoring
    // inputs) plus the overlay closure; bundling them into a struct would only
    // relocate the same fields without reducing real complexity.
    #[allow(clippy::too_many_arguments)]
    fn score_candidates<F>(
        &self,
        candidates: &[EntityId],
        profile: &RankingProfile,
        now: Timestamp,
        session_ctx: Option<&SessionContext>,
        item_metadata: &HashMap<u64, HashMap<String, String>>,
        retrieval_scores: Option<&HashMap<u64, f64>>,
        mut extra_boost: F,
    ) -> crate::Result<Vec<ScoredCandidate>>
    where
        F: FnMut(EntityId, &mut Vec<(String, f64)>) -> f64,
    {
        let empty: HashMap<String, String> = HashMap::new();
        // Lowercase the session keywords ONCE, before the candidate loop, rather
        // than re-lowercasing every keyword for every candidate inside
        // `session_boost` (CLEAN-W, M0-M10 review). Empty when there is no session.
        let session_keywords = session_ctx.map(Self::lowered_session_keywords);
        // Query clock, captured ONCE: every per-candidate time-dependent read
        // (excludes, gates, and the scoring reads below) ages to this single
        // "now" so the whole result set is consistent and re-running the same
        // query against an unchanged ledger is byte-identical (Accuracy-W).
        let now_ns = now.as_nanos();
        let mut scored: Vec<ScoredCandidate> = Vec::new();
        for &entity_id in candidates {
            // Stage 2: hard exclusion -- remove content the user must never see.
            if !passes_excludes(entity_id, &profile.excludes, self.ledger, now_ns)? {
                continue;
            }
            // Stage 6: gates -- remove candidates below quality thresholds.
            if !passes_gates(entity_id, &profile.gates, self.ledger, now_ns)? {
                continue;
            }
            let (raw, mut snapshot) =
                self.compute_raw_score(entity_id, profile, now, retrieval_scores)?;
            let metadata = item_metadata.get(&entity_id.as_u64()).map_or(&empty, |m| m);
            let session_boost = session_ctx.map_or(0.0, |ctx| {
                Self::session_boost(
                    session_keywords.as_deref().unwrap_or_default(),
                    ctx,
                    metadata,
                )
            });
            let overlay = extra_boost(entity_id, &mut snapshot);
            scored.push(ScoredCandidate {
                entity_id,
                score: finite_score(raw + session_boost + overlay),
                signal_snapshot: snapshot,
                creator_id: None,
                format: None,
            });
        }

        self.finalize(&mut scored, profile.sort.as_ref());
        Ok(scored)
    }

    /// Whitespace-split and lowercase a session's keyword hints **once** per
    /// scoring call.
    ///
    /// [`Self::session_boost`] runs per candidate; re-lowercasing the same session
    /// keywords for every candidate is pure waste on the scoring hot path (CLEAN-W,
    /// M0-M10 review). The callers hoist this out of the candidate loop and pass the
    /// result to `session_boost`. `SessionContext::keywords` are nominally already
    /// whitespace-split and lowercased, but we re-split/-lowercase defensively so a
    /// directly-constructed context with multi-word or mixed-case hints still yields
    /// the same token count and match set as before.
    fn lowered_session_keywords(ctx: &SessionContext) -> Vec<String> {
        ctx.keywords
            .iter()
            .flat_map(|h| h.split_whitespace())
            .map(str::to_lowercase)
            .collect()
    }

    /// Compute the session-specific score boost for a single entity.
    ///
    /// - Keyword hint score: fraction of annotation keywords that match any metadata value.
    ///   `hint_score = hits / total_keywords * 0.3` (capped via the [0,1] fraction).
    /// - Velocity boost: `vel / (vel + 1) * 0.2` (Michaelis-Menten saturation).
    ///
    /// `lowered_keywords` are the already-lowercased keyword tokens produced once by
    /// [`Self::lowered_session_keywords`]; only the per-candidate `metadata` values
    /// are lowercased here.
    fn session_boost(
        lowered_keywords: &[String],
        ctx: &SessionContext,
        metadata: &HashMap<String, String>,
    ) -> f64 {
        let total_kw = lowered_keywords.len();
        let hits = lowered_keywords
            .iter()
            .filter(|kw| {
                metadata
                    .values()
                    .any(|v| v.to_lowercase().contains(kw.as_str()))
            })
            .count();
        #[allow(clippy::cast_precision_loss)]
        let hint_score = if total_kw == 0 {
            0.0
        } else {
            hits as f64 / total_kw as f64
        };
        let vel_norm = ctx.reward_velocity / (ctx.reward_velocity + 1.0);
        hint_score * 0.3 + vel_norm * 0.2
    }

    /// Compute raw score and signal snapshot for a single candidate.
    ///
    /// Returns `(score, snapshot)` where `snapshot` lists the raw signal values
    /// that contributed, for explain-ability in API responses.
    ///
    /// Implements the additive portion of the ranking pipeline (spec
    /// 09-ranking-scoring.md): the sort-mode base, the optional retrieval
    /// (fused RRF) relevance seed, Stage 4 boosts, the co-engagement blend, and
    /// Stage 5 penalties, finally scaled by the optional Stage-equivalent
    /// `ProfileDecay` recency factor. Hard exclusion (Stage 2) and gates
    /// (Stage 6) are applied by the callers via [`passes_excludes`] /
    /// [`passes_gates`] because they *remove* candidates rather than adjust a
    /// score.
    ///
    /// **Sort overrides boosts/penalties (spec §11 / §11.9).** When a `sort` mode
    /// is active its formula "replaces stages 4-5 (boost and penalty application)"
    /// — so the Stage-4 boost loop and Stage-5 penalty loop are *skipped* whenever
    /// `profile.sort.is_some()`. This removes the `trending`/`cohort_trending`
    /// double-count (the `Sort::Trending` base and the profile boosts both read
    /// `view`/`share` velocity over the same window; adding them produced
    /// `2*view_vel + 4*share_vel` — order-identical to the spec formula but a
    /// latent hybrid the spec forbids). The co-engagement blend and `ProfileDecay`
    /// recency factor are *not* boosts/penalties and still apply. The separate
    /// cohort-scoped rescore (query Stage 3b) reads `profile.boosts` directly and
    /// is unaffected — it overwrites this score entirely for cohort queries.
    ///
    /// **Clock contract.** `now` is converted to `now_ns` once and threaded into
    /// every time-dependent ledger read (sort base, boosts, penalties, decay) via
    /// the explicit-clock `_at` variants, so scores are a pure function of
    /// `(ledger snapshot, now)` (Accuracy-W, M0-M10 review).
    ///
    /// `retrieval_scores`, when present, maps `entity_id -> normalized fused/RRF
    /// relevance score in [0, 1]` (produced by SEARCH Stage 1c). The seed makes
    /// the fused order the primary ordering signal when profile boosts are light;
    /// see [`RELEVANCE_BASE_WEIGHT`]. Pass `None` for non-relevance-seeded paths
    /// (RETRIEVE, browse) where the base starts at the sort-mode score alone.
    ///
    /// # Errors
    ///
    /// Propagates an unimplemented-aggregation error from the sort mode, a boost
    /// term, or a penalty term. Boosts and penalties read via
    /// [`read_agg_for_sort`]: a schema-defined profile's declared signals are
    /// validated present at registration, while built-in profiles read a generic
    /// vocabulary that a given application schema may omit -- a missing signal
    /// contributes 0.0 rather than erroring (a typo in a declared term is caught
    /// at registration, not here).
    fn compute_raw_score(
        &self,
        entity_id: EntityId,
        profile: &RankingProfile,
        now: Timestamp,
        retrieval_scores: Option<&HashMap<u64, f64>>,
    ) -> crate::Result<(f64, Vec<(String, f64)>)> {
        let now_ns = now.as_nanos();
        let (sort_base, mut snapshot) =
            self.score_by_sort(entity_id, profile.sort.as_ref(), now)?;

        // Retrieval relevance seed (SEARCH Stage 1c). When the caller threads a
        // fused RRF score, fold it into the base so the fused order anchors the
        // ranking and the profile's signal boosts act as a quality overlay.
        let mut base = sort_base;
        if let Some(scores) = retrieval_scores
            && let Some(&relevance) = scores.get(&entity_id.as_u64())
        {
            let weighted = RELEVANCE_BASE_WEIGHT * relevance;
            base += weighted;
            if weighted != 0.0 {
                snapshot.push(("relevance".to_string(), weighted));
            }
        }

        // Stage 4: boosts. Capture each contribution in the snapshot.
        //
        // Spec §11.9: a formula sort "replaces stages 4-5" only for the boosts it
        // already SUBSUMES — a boost whose (signal, agg, window) the sort formula
        // itself reads. The `trending`/`cohort_trending` builtins keep their
        // view/share velocity boosts on the profile purely as the cohort-rescore
        // signal definition (Stage 3b); on the global path those exact boosts ARE
        // the `Sort::Trending` formula, so re-applying them would double-count.
        // Every other profile's boosts (the `for_you`/`following`/`related`/
        // `notification` engagement overlays) read different signals/aggs/windows
        // than their sort formula, so they are NOT subsumed and DO apply on top of
        // the sort base — preserving each profile's intended engagement weighting.
        let mut boost_sum = 0.0;
        for b in &profile.boosts {
            if sort_subsumes_boost(profile.sort.as_ref(), &b.signal, &b.agg, b.window) {
                continue;
            }
            let val = read_agg_for_sort(
                entity_id,
                &b.signal,
                &b.agg,
                b.window,
                self.ledger,
                self.degradation_level,
                now_ns,
            )?;
            let weighted = b.weight * val;
            if weighted != 0.0 {
                snapshot.push((format!("{}_boost", b.signal), weighted));
            }
            boost_sum += weighted;
        }

        // M6: co-engagement blend for `related`-style queries.
        //
        // The spec states: `final = embedding_sim × 0.6 + co_engagement × 0.3 + signal_score × 0.1`
        // (a normalized convex combination). The actual formula is additive rather than
        // a strict convex combination because embedding similarity is not available as
        // a per-candidate scoring signal in the executor: ANN retrieval (Stage 1) uses
        // the embedding for candidate *selection*, not for re-scoring. The 0.3 co-engagement
        // weight is correct; incorporating embedding_sim as a multiplicative factor requires
        // storing per-candidate ANN distances and plumbing them through the executor context
        // (deferred to a future milestone).
        //
        // Effective formula: base_signal_score + boost_sum + co_eng_score × 0.3
        let co_eng_boost = if let (Some(co_eng), Some(seed)) = (self.co_engagement, self.seed_item)
        {
            let boost = f64::from(co_eng.score(seed, entity_id)) * 0.3;
            if boost != 0.0 {
                snapshot.push(("co_engagement".to_string(), boost));
            }
            boost
        } else {
            0.0
        };

        // Stage 5: penalties. Negative signals (skip/dislike/downvote) are
        // first-class (Design Principle 3): each penalty subtracts
        // `weight * agg(signal, window)`, mirroring the boost loop with the
        // opposite sign. `weight` is stored positive and applied as `-weight`.
        // No builtin formula sort reads a penalty signal, so penalties are never
        // subsumed by a sort and always apply (a penalty is a complementary
        // demotion overlay, not a re-statement of the sort formula).
        let mut penalty_sum = 0.0;
        for p in &profile.penalties {
            let val = read_agg_for_sort(
                entity_id,
                &p.signal,
                &p.agg,
                p.window,
                self.ledger,
                self.degradation_level,
                now_ns,
            )?;
            let weighted = p.weight * val;
            if weighted != 0.0 {
                snapshot.push((format!("{}_penalty", p.signal), -weighted));
            }
            penalty_sum += weighted;
        }

        let additive = base + boost_sum + co_eng_boost - penalty_sum;

        // `ProfileDecay`: an exponential recency factor applied *multiplicatively*
        // (spec 09 §5.4: `candidate.score *= recency_score`), so aged content's
        // whole composite score decays proportionally rather than being offset by
        // a fixed amount. The implemented `ProfileDecay` names a signal whose
        // running `DecayScore` (an exponentially-weighted quantity already aged
        // forward to read time by its half-life) stands in for content freshness:
        // an item whose decay signal last fired long ago has a near-zero score and
        // is demoted, while a freshly-active item keeps its full score. `weight`
        // in `[0, 1]` blends the factor toward 1.0 so `weight = 0` is a no-op and
        // `weight = 1` applies the full decay. `half_life_secs` is informational
        // here -- the half-life is already encoded in the signal's decay rate.
        let decayed = if let Some(decay) = &profile.decay {
            let recency = read_agg_for_sort(
                entity_id,
                &decay.signal,
                &SignalAgg::DecayScore,
                Window::AllTime,
                self.ledger,
                self.degradation_level,
                now_ns,
            )?
            .clamp(0.0, 1.0);
            let factor = decay.weight.mul_add(recency, 1.0 - decay.weight);
            if (factor - 1.0).abs() > f64::EPSILON {
                snapshot.push((format!("{}_decay", decay.signal), factor));
            }
            additive * factor
        } else {
            additive
        };

        Ok((decayed, snapshot))
    }

    /// Apply the post-scoring tail shared by every scoring entry point:
    /// sort by descending score (with a deterministic tie-break), then min-max
    /// normalize to `[0, 1]`.
    ///
    /// **Tie-breaking.** The primary key is the descending `score`. Equal scores
    /// are broken deterministically so the result order is reproducible rather
    /// than left to the unstable sort:
    /// - For `AlphabeticalAsc` / `AlphabeticalDesc`, two titles sharing the same
    ///   8-byte prefix collide on the f64 score (a single f64 cannot hold a full
    ///   title). The tie is broken on the **full** lowercased title in the sort's
    ///   direction (ascending or descending), so e.g. "introduction to go" and
    ///   "introduction to rust" order correctly past the 8th character.
    /// - For every mode, ascending entity id is the final fallback (covers
    ///   identical titles and any other exact score tie), guaranteeing a total,
    ///   reproducible order.
    ///
    /// **No in-executor diversity.** The executor never populates `creator_id` /
    /// `format` on its `ScoredCandidate`s (those require entity metadata the
    /// executor does not load), so a `DiversitySelector` pass at this layer would
    /// always see `None` for both dimensions and silently do nothing. Genuine
    /// diversity enforcement runs later in the query pipeline, where candidate
    /// metadata is available; doing it here would be a dead, redundant clone of
    /// the result set on the hot path. See the WARNING in the M0–M10 review.
    fn finalize(&self, scored: &mut [ScoredCandidate], sort: Option<&super::profile::Sort>) {
        use super::profile::Sort;

        // Direction of the alphabetical full-title tie-break, if any. Ascending
        // names rank first under `AlphabeticalAsc`; descending under `...Desc`.
        let alpha_dir = match sort {
            Some(Sort::AlphabeticalAsc) => Some(true),
            Some(Sort::AlphabeticalDesc) => Some(false),
            _ => None,
        };

        // Pre-read each candidate's lowercased title ONCE for alphabetical sorts,
        // rather than re-reading (and re-allocating) two `String`s per equal-score
        // comparison inside the comparator (CLEAN-W, M0-M10 review). The comparator
        // then borrows from this map, so the hot tie-break path allocates nothing.
        // Built only when an alphabetical tie-break is active; otherwise the title
        // is never consulted and we skip the scan entirely.
        let titles: HashMap<u64, String> = if alpha_dir.is_some() {
            scored
                .iter()
                .filter_map(|c| {
                    self.read_title(c.entity_id)
                        .map(|t| (c.entity_id.as_u64(), t))
                })
                .collect()
        } else {
            HashMap::new()
        };

        // `sort_unstable_by` is safe here: ranking score ordering is not stability-sensitive.
        // `select_nth_unstable_by` optimization requires knowing `limit` at this site;
        // truncation is applied by the caller after normalization.
        // `total_cmp` is a guaranteed total order; scores are finite by construction
        // (`finite_score`), so no `NaN` can reach the comparator and scramble the rank.
        scored.sort_unstable_by(|a, b| {
            // Primary: descending score.
            let by_score = b.score.total_cmp(&a.score);
            if by_score != std::cmp::Ordering::Equal {
                return by_score;
            }
            // Tie-break 1: full title for alphabetical sorts (8-byte-prefix
            // collisions order correctly past the prefix, in the sort direction).
            // Titles are borrowed from the pre-built `titles` map; a titleless
            // candidate is absent and compares as `None`, exactly as the prior
            // `read_title`-returns-`None` path did (a missing title already scores
            // -1.0, so it sorts last on the primary key and only ties another
            // missing title here, which then falls through to the id tie-break).
            if let Some(ascending) = alpha_dir {
                let ta = titles.get(&a.entity_id.as_u64());
                let tb = titles.get(&b.entity_id.as_u64());
                let by_title = if ascending { ta.cmp(&tb) } else { tb.cmp(&ta) };
                if by_title != std::cmp::Ordering::Equal {
                    return by_title;
                }
            }
            // Tie-break 2: ascending entity id -- a deterministic total order.
            a.entity_id.as_u64().cmp(&b.entity_id.as_u64())
        });

        // Normalize to [0, 1].
        normalize(scored);
    }
}

/// Whether the active formula `sort` already incorporates this boost's signal, so
/// re-applying it on the global scoring path would double-count (spec §11.9).
///
/// Today only [`Sort::Trending`] subsumes its boosts: its formula reads
/// `view`/`share` velocity over 24h, exactly the velocity boosts the
/// `trending`/`cohort_trending` builtins carry — kept on the profile only as the
/// cohort-rescore signal definition (Stage 3b). Every other sort's boosts read a
/// different (signal, agg, window) than the sort formula (e.g. `for_you`'s
/// `Sort::Hot` ranks by view *count* while its boosts read view/like *decay
/// scores*), so they are complementary overlays and are never subsumed.
fn sort_subsumes_boost(
    sort: Option<&super::profile::Sort>,
    signal: &str,
    agg: &SignalAgg,
    window: Window,
) -> bool {
    use super::profile::Sort;
    matches!(sort, Some(Sort::Trending))
        && matches!(agg, SignalAgg::Velocity)
        && window == Window::TwentyFourHours
        && (signal == "view" || signal == "share")
}

mod scoring;

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::cast_precision_loss)]
mod tests;

/// Regression coverage for the M0-M10 review's ranking CLEAN warnings: hoisting
/// the session-keyword lowercasing out of the per-candidate loop (W31) and
/// pre-reading titles once for the alphabetical tie-break (W30) must leave the
/// observable scoring behavior unchanged.
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::float_cmp)]
mod hotpath_regression_tests {
    use std::collections::{HashMap, HashSet};

    use super::ProfileExecutor;
    use crate::{
        ranking::{
            profile::Sort,
            test_fixtures::{ledger_for, minimal_profile, profile_with_sort},
        },
        schema::{EntityId, Timestamp},
        session::SessionContext,
    };

    fn session_with_keywords(keywords: &[&str]) -> SessionContext {
        SessionContext {
            keywords: keywords.iter().map(|k| (*k).to_string()).collect(),
            reward_velocity: 0.0,
            metadata: HashMap::new(),
            signaled_entities: HashSet::new(),
        }
    }

    #[test]
    fn session_boost_keyword_match_is_case_insensitive() {
        // W31: keywords are lowercased ONCE before the candidate loop. A mixed-case
        // keyword must still match a metadata value case-insensitively, exactly as
        // the prior per-candidate `to_lowercase()` did. With one of two keywords
        // matching, the hint fraction is 0.5 and the boost is `0.5 * 0.3 = 0.15`
        // (reward_velocity is 0, so the velocity term contributes nothing). The
        // boost lands as the candidate's raw score, which normalizes to 1.0, so we
        // assert the match via the score relationship across two items.
        let ledger = ledger_for(&["view"]);
        let executor = ProfileExecutor::new(&ledger);
        let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
        // Entity 1's title matches "RUST" (case-insensitively); entity 2 matches
        // neither keyword, so it receives no hint boost.
        meta.insert(
            1,
            [("title".to_string(), "The Rust Book".to_string())].into(),
        );
        meta.insert(2, [("title".to_string(), "Cooking 101".to_string())].into());

        // No sort: both candidates have a deterministic base of 0.0, so the only
        // score difference is the session keyword boost (no hash noise to mask it).
        let profile = minimal_profile("session_kw");
        let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
        let candidates = vec![EntityId::new(1), EntityId::new(2)];
        // "RUST" (mixed case relative to the lowercased title) plus a non-matching
        // keyword: 1 of 2 keyword tokens hit for entity 1, 0 of 2 for entity 2.
        let session = session_with_keywords(&["RUST", "zzz"]);

        let scored = executor
            .score_with_session(&candidates, &profile, now, Some(&session), &meta, None)
            .unwrap();

        let e1 = scored
            .iter()
            .find(|c| c.entity_id == EntityId::new(1))
            .unwrap();
        let e2 = scored
            .iter()
            .find(|c| c.entity_id == EntityId::new(2))
            .unwrap();
        // The matching item (raw 0.15) must outrank the non-matching one (raw 0.0);
        // after min-max normalization that is 1.0 vs 0.0.
        assert_eq!(e1.score, 1.0);
        assert_eq!(e2.score, 0.0);
    }

    #[test]
    fn session_boost_empty_keywords_is_noop() {
        // No keywords and zero reward velocity: the session boost is exactly 0.0,
        // so the result matches the no-session path. Guards the empty-keyword
        // slice path on the hoisted lowercasing.
        let ledger = ledger_for(&["view"]);
        let executor = ProfileExecutor::new(&ledger);
        let meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
        let profile = profile_with_sort(Sort::Shuffle);
        let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
        let candidates = vec![EntityId::new(1), EntityId::new(2), EntityId::new(3)];
        let session = session_with_keywords(&[]);

        let with_empty_session = executor
            .score_with_session(&candidates, &profile, now, Some(&session), &meta, None)
            .unwrap();
        let no_session = executor.score(&candidates, &profile, now).unwrap();

        for (a, b) in with_empty_session.iter().zip(no_session.iter()) {
            assert_eq!(a.entity_id, b.entity_id);
            assert_eq!(a.score, b.score);
        }
    }

    #[test]
    fn alphabetical_tie_break_uses_full_title_past_prefix() {
        // W30: the alphabetical tie-break reads each title once into a map and
        // borrows from it, rather than re-allocating two `String`s per comparison.
        // Titles sharing the first 8 bytes ("introduc") collide on the f64 score;
        // the full-title fallback must still order them deterministically: asc
        // gives "...go" < "...rust" < "...zig" -> entities 2, 1, 3.
        let ledger = ledger_for(&["view"]);
        let mut meta: HashMap<u64, HashMap<String, String>> = HashMap::new();
        meta.insert(
            1,
            [("title".to_string(), "introduction to rust".to_string())].into(),
        );
        meta.insert(
            2,
            [("title".to_string(), "introduction to go".to_string())].into(),
        );
        meta.insert(
            3,
            [("title".to_string(), "introduction to zig".to_string())].into(),
        );

        let executor = ProfileExecutor::new(&ledger).with_item_metadata(&meta);
        let candidates = vec![EntityId::new(3), EntityId::new(1), EntityId::new(2)];
        let now = Timestamp::from_nanos(1_708_000_000_000_000_000);
        let profile = profile_with_sort(Sort::AlphabeticalAsc);

        let scored = executor.score(&candidates, &profile, now).unwrap();
        let order: Vec<u64> = scored.iter().map(|c| c.entity_id.as_u64()).collect();
        assert_eq!(
            order,
            vec![2, 1, 3],
            "full-title tie-break must order prefix-colliding titles alphabetically"
        );
    }
}
