//! Stage 3b helper methods for the RETRIEVE executor.
//!
//! Contains cohort rescoring, notification cap enforcement, and related helpers.

use super::RetrieveExecutor;
use crate::{
    db::deserialize_metadata as deserialize_item_metadata,
    query::retrieve::QueryError,
    ranking::executor::ScoredCandidate,
    schema::EntityId,
    storage::{Tag, encode_key},
};

/// Degrade an unknown-signal schema error to a default; propagate anything else.
///
/// Mirrors the main scoring path's `read_agg_for_sort`
/// (`ranking::executor::helpers`): a signal the schema does not define is not a
/// query error — the cohort ledger has no entries for it, so it contributes the
/// neutral `default` (`0` / `0.0`) just as the main boost loop scores a missing
/// signal `0.0`. EVERY OTHER error (e.g. a poisoned-clock panic surfaced as an
/// error, or a future fallible code path) propagates as a loud
/// [`QueryError::InvalidFilter`] instead of being silently swallowed by the
/// former blanket `unwrap_or`. Keeping this consistent with the main path is the
/// point of W26: the cohort override must not quietly mask a real failure.
fn degrade_unknown_signal<T>(
    result: crate::Result<T>,
    signal: &str,
    default: T,
) -> Result<T, QueryError> {
    match result {
        Ok(value) => Ok(value),
        Err(crate::TidalError::Schema(crate::schema::error::SchemaError::UnknownSignalType(_))) => {
            Ok(default)
        }
        Err(other) => Err(QueryError::InvalidFilter {
            field: format!("cohort boost '{signal}'"),
            reason: other.to_string(),
        }),
    }
}

/// Min-max normalize candidate scores into `[0.0, 1.0]` in place.
///
/// Mirrors the ranking executor's own post-scoring normalization so the
/// `RetrieveResult.score` contract holds after a cohort rescore:
/// - Non-finite scores (`NaN`/`±Inf`) are clamped to `0.0` first.
/// - If every score is equal (`range < EPSILON`), all map to `0.5` (spec §8.2:
///   a degenerate all-equal set is "neutral / no signal", not maximal
///   confidence) — kept identical to the ranking executor's `normalize` so the
///   two paths cannot drift.
/// - Otherwise `(score - min) / (max - min)`.
fn normalize_scores(candidates: &mut [ScoredCandidate]) {
    if candidates.is_empty() {
        return;
    }
    for c in candidates.iter_mut() {
        if !c.score.is_finite() {
            c.score = 0.0;
        }
    }
    let min = candidates
        .iter()
        .map(|c| c.score)
        .fold(f64::INFINITY, f64::min);
    let max = candidates
        .iter()
        .map(|c| c.score)
        .fold(f64::NEG_INFINITY, f64::max);
    let range = max - min;
    for c in candidates.iter_mut() {
        c.score = if range < f64::EPSILON {
            0.5
        } else {
            (c.score - min) / range
        };
    }
}

impl RetrieveExecutor<'_> {
    // ── Stage 3b helpers (M6) ────────────────────────────────────────────

    /// Resolve an ad-hoc cohort predicate to a registered cohort name.
    ///
    /// Reads the user's metadata from items storage, evaluates the predicate,
    /// and then checks which registered cohorts the user belongs to.
    ///
    /// # Returns
    ///
    /// - `Ok(Some(name))` when a registered cohort matches the user.
    /// - `Ok(None)` when the user does not match the predicate, or when
    ///   metadata cannot be read (storage miss is recoverable).
    /// - `Err(QueryError::InvalidFilter)` when the user matches the predicate
    ///   but no registered cohort is defined for it.
    pub(super) fn resolve_adhoc_cohort(
        &self,
        pred: &crate::cohort::Predicate,
        user_id: u64,
        warnings: &mut Vec<String>,
    ) -> Result<Option<String>, QueryError> {
        // Read user metadata from items storage (users are stored there too
        // via write_user). We need users_engine, but the executor only has
        // items_storage. Fall back to items_storage for metadata lookup.
        let Some(storage) = self.items_storage else {
            warnings.push(format!(
                "cohort_predicate: no storage available for user {user_id}; ignoring"
            ));
            return Ok(None);
        };

        let user_entity_id = EntityId::new(user_id);
        let key = encode_key(user_entity_id, Tag::Meta, b"");
        let meta_bytes = match storage.get(&key) {
            Ok(Some(bytes)) => bytes,
            Ok(None) => {
                warnings.push(format!(
                    "cohort_predicate: user {user_id} has no metadata; ignoring"
                ));
                return Ok(None);
            }
            Err(_) => {
                warnings.push(format!(
                    "cohort_predicate: failed to read metadata for user {user_id}; ignoring"
                ));
                return Ok(None);
            }
        };

        let metadata = deserialize_item_metadata(&meta_bytes);

        // Evaluate the ad-hoc predicate against the user's metadata.
        if !crate::cohort::resolver::evaluate_predicate(pred, &metadata) {
            warnings.push(format!(
                "cohort_predicate: user {user_id} does not match the predicate; ignoring"
            ));
            return Ok(None);
        }

        // Find which registered cohorts this user belongs to.
        let Some(registry) = self.cohort_registry else {
            return Err(QueryError::InvalidFilter {
                field: "cohort_predicate".to_string(),
                reason: "user matches predicate but no cohort registry is available; \
                         use define_cohort() to register a cohort before querying \
                         with cohort_predicate"
                    .to_string(),
            });
        };

        let cohort_names = registry.names();
        for name in &cohort_names {
            if let Some(def) = registry.get(name)
                && crate::cohort::resolver::evaluate_predicate(&def.predicate, &metadata)
            {
                return Ok(Some(name.to_string()));
            }
        }

        Err(QueryError::InvalidFilter {
            field: "cohort_predicate".to_string(),
            reason: "user matches predicate but no registered cohort is defined for it; \
                     use define_cohort() to register a cohort before querying \
                     with cohort_predicate"
                .to_string(),
        })
    }

    /// Re-score candidates using cohort signal values and re-sort.
    ///
    /// # Sort-mode preservation (C-CRITICAL)
    ///
    /// The main scoring path bakes the profile's [`Sort`] into each
    /// `ScoredCandidate.score` (e.g. `New` scores by entity id, `AlphabeticalAsc`
    /// by title). A naive cohort rescore that OVERWRITES every score with a sum
    /// derived only from `profile.boosts` would destroy that ordering: for a
    /// sort-driven profile with empty boosts (e.g. the built-in `new`), every
    /// cohort score collapses to `0.0`, normalization maps them all to `1.0`, and
    /// the descending re-sort is a no-op — silently returning uniform scores and
    /// the wrong order. To prevent that, the cohort override is applied ONLY when
    /// it can change ranking without discarding a sort the main path already
    /// honored:
    ///
    /// - **No boosts** → the cohort scope cannot change a boost-free profile's
    ///   ranking, so the rescore is a no-op (main-path scores and order preserved).
    /// - **A metadata / id-based sort is active** (`New`, `Alphabetical*`,
    ///   `Shortest`, `Longest`, `DateSaved`, `Shuffle`) → that ordering reads entity
    ///   metadata / id, NOT the cohort ledger, so it must survive; the rescore is a
    ///   no-op and the main-path order is kept.
    /// - **Otherwise** (a signal-driven sort or no sort, with boosts present) →
    ///   re-derive the score from the cohort ledger's signal values so the cohort's
    ///   own engagement drives ranking, then re-normalize and re-sort.
    ///
    /// # Errors
    ///
    /// Returns [`QueryError::InvalidFilter`] when a boost references an
    /// aggregation that has no executor implementation (`Ratio` /
    /// `RelativeVelocity`), or when a cohort ledger read fails for any reason
    /// other than an unknown signal name. This mirrors the main scoring path
    /// (`ranking::executor::helpers::read_agg` / `read_agg_for_sort`): an unknown
    /// signal degrades to `0.0` (the ledger holds no entries for it), but every
    /// other error propagates loudly instead of being swallowed by an
    /// `unwrap_or` (W26). The profile registry rejects the unsupported
    /// aggregations up front (`ProfileError::UnsupportedAggregation`), so reaching
    /// that error arm means a profile bypassed validation — failing loud beats a
    /// silently-wrong cohort ranking. Keeping this consistent with the main path
    /// is the point: the cohort override must not score `0.0` where direct
    /// scoring would error.
    #[allow(clippy::unused_self)]
    pub(super) fn rescore_with_cohort(
        &self,
        scored: &mut [crate::ranking::executor::ScoredCandidate],
        cohort_name: &str,
        cohort_ledger: &crate::cohort::CohortSignalLedger,
        profile: &crate::ranking::profile::RankingProfile,
    ) -> Result<(), QueryError> {
        use crate::ranking::profile::{SignalAgg, Sort};

        let boosts = &profile.boosts;

        // A boost-free profile's ranking is driven entirely by its sort mode or by
        // the main-path base score; the cohort scope changes neither. Preserve the
        // main-path scores and order rather than collapsing every candidate to a
        // uniform cohort-boost sum of 0.0 (the exact `new`-profile bug C flagged).
        if boosts.is_empty() {
            return Ok(());
        }

        // A metadata / id-based sort reads entity metadata or id, never the cohort
        // ledger. Overwriting the score from cohort boosts would destroy that
        // ordering, so leave the main-path scores and order intact for these sorts.
        if matches!(
            profile.sort,
            Some(
                Sort::New
                    | Sort::AlphabeticalAsc
                    | Sort::AlphabeticalDesc
                    | Sort::Shortest
                    | Sort::Longest
                    | Sort::DateSaved
                    | Sort::Shuffle
            )
        ) {
            return Ok(());
        }

        for candidate in scored.iter_mut() {
            let mut cohort_score = 0.0;
            // Rebuild the signal snapshot from the cohort-scoped values so the
            // reported `RetrieveResult.signals` reflect the cohort scope the score
            // was derived from, not the stale global-ledger snapshot the main path
            // left on the candidate (C-CRITICAL: snapshot consistency).
            let mut cohort_snapshot: Vec<(String, f64)> = Vec::with_capacity(boosts.len());
            for boost in boosts {
                let value = match &boost.agg {
                    SignalAgg::Value => {
                        // Degrade an unknown signal to 0 (the ledger has no
                        // entries for it), but surface any other error loudly —
                        // matching the main scoring path's `read_agg_for_sort`
                        // rather than the former blanket `unwrap_or(0)` (W26).
                        let count = degrade_unknown_signal(
                            cohort_ledger.read_windowed_count(
                                cohort_name,
                                candidate.entity_id,
                                &boost.signal,
                                boost.window,
                            ),
                            &boost.signal,
                            0,
                        )?;
                        #[allow(clippy::cast_precision_loss)]
                        {
                            count as f64
                        }
                    }
                    SignalAgg::Velocity => degrade_unknown_signal(
                        cohort_ledger.read_velocity(
                            cohort_name,
                            candidate.entity_id,
                            &boost.signal,
                            boost.window,
                        ),
                        &boost.signal,
                        0.0,
                    )?,
                    SignalAgg::DecayScore => degrade_unknown_signal(
                        cohort_ledger.read_decay_score(
                            cohort_name,
                            candidate.entity_id,
                            &boost.signal,
                            0,
                        ),
                        &boost.signal,
                        None,
                    )?
                    .unwrap_or(0.0),
                    // `Ratio` / `RelativeVelocity` have no executor implementation.
                    // The registry rejects them at profile registration, so this is
                    // an invariant violation if reached — surface it the same way
                    // the main scoring path does instead of silently scoring 0.0.
                    agg @ (SignalAgg::Ratio | SignalAgg::RelativeVelocity) => {
                        return Err(QueryError::InvalidFilter {
                            field: format!("cohort boost '{}'", boost.signal),
                            reason: format!("aggregation '{}' is not implemented", agg.label()),
                        });
                    }
                };
                cohort_score += value * boost.weight;
                cohort_snapshot.push((boost.signal.clone(), value));
            }
            candidate.score = cohort_score;
            candidate.signal_snapshot = cohort_snapshot;
        }

        // Re-normalize: cohort rescore replaced each candidate's normalized
        // `[0, 1]` score with an un-normalized `value * weight` sum, which would
        // break the documented `RetrieveResult.score in [0, 1]` contract. Min-max
        // over the rescored set restores it (this is the same normalization the
        // ranking executor applies after its own scoring pass).
        normalize_scores(scored);

        // Re-sort by descending score, breaking ties on ASCENDING entity id so the
        // order is a true total order (W14) — without it, candidates that tie on
        // the rescored cohort score keep the input order, which can differ across
        // runs once the upstream candidate order does.
        scored.sort_by(|a, b| {
            b.score
                .partial_cmp(&a.score)
                .unwrap_or(std::cmp::Ordering::Equal)
                .then_with(|| a.entity_id.as_u64().cmp(&b.entity_id.as_u64()))
        });

        Ok(())
    }

    /// Days since the Unix epoch for the current wall clock.
    ///
    /// Shared by the cap *filter* (pre-pagination) and the cap *record*
    /// (post-pagination) so both bucket deliveries into the same calendar day.
    /// `u32` wraps at ~4.3 billion days (~11.8 million years); safe for any
    /// practical use. The `86_400s * 1e9ns` constant avoids a `chrono` dep.
    fn today_days_since_epoch() -> u32 {
        const NANOS_PER_DAY: u64 = 86_400 * 1_000_000_000;
        let now_ns = crate::schema::Timestamp::now().as_nanos();
        (now_ns / NANOS_PER_DAY) as u32
    }

    /// Resolve a candidate's `creator_id`: prefer the pre-populated field, fall
    /// back to reading item metadata from storage. Returns `None` for items with
    /// no resolvable creator (which skip the per-creator cap by design).
    fn resolve_candidate_creator_id(
        &self,
        c: &crate::ranking::executor::ScoredCandidate,
    ) -> Option<u64> {
        if let Some(cid) = c.creator_id {
            return Some(cid.as_u64());
        }
        let storage = self.items_storage?;
        let key = encode_key(c.entity_id, Tag::Meta, b"");
        let raw = storage.get(&key).ok()??;
        let meta = deserialize_item_metadata(&raw);
        meta.get("creator_id")?.parse::<u64>().ok()
    }

    /// Apply notification caps as a post-diversity, pre-pagination FILTER.
    ///
    /// Enforces per-creator and total daily limits by consulting the
    /// `NotificationTracker` for historical deliveries and capping within this
    /// response against a read-only snapshot of prior deliveries. This pass NEVER
    /// records: recording is deferred to [`Self::record_notification_deliveries`],
    /// applied to the *paginated page* in Stage 5.
    ///
    /// # Why filtering and recording are split (C-CRITICAL)
    ///
    /// Pagination (offset/limit slicing) happens in Stage 5, AFTER this filter. If
    /// this pass also recorded (the previous behavior), a `limit=5` query over 50
    /// survivors recorded 50 deliveries while returning 5 — the daily cap was
    /// consumed by 45 items the caller never received, silently exhausting the
    /// budget far below the real delivery count and suppressing legitimate
    /// notifications. Recording only the delivered page fixes that: a delivery is
    /// counted iff it is actually returned to the caller.
    ///
    /// Creator resolution: `ScoredCandidate.creator_id` is populated by the
    /// ranking executor only when diversity is evaluated. As a fallback, we resolve
    /// it from item metadata via `items_storage`.
    pub(super) fn apply_notification_caps(
        &self,
        candidates: Vec<crate::ranking::executor::ScoredCandidate>,
        caps: crate::query::retrieve::NotificationCaps,
        user_id: u64,
        _profile_name: &str,
    ) -> Vec<crate::ranking::executor::ScoredCandidate> {
        use std::collections::HashMap as StdMap;

        let today = Self::today_days_since_epoch();

        // Read-only snapshot of prior deliveries. Nothing is persisted here, so
        // there is no shared state to race on — the per-response accumulators are
        // local to this call. The actual record happens on the delivered page.
        let already_total = self
            .notification_tracker
            .map_or(0, |t| t.count_total_today(user_id, today));

        let mut per_creator: StdMap<u64, u32> = StdMap::new();
        let mut in_response_total: u32 = 0;

        candidates
            .into_iter()
            .filter(|c| {
                // Enforce total cap across both prior requests and this response.
                if already_total + in_response_total >= caps.max_total_per_day {
                    return false;
                }

                // Per-creator cap: only enforced when the item has a resolvable creator.
                // Items without any creator_id (neither in the candidate nor in metadata)
                // skip the per-creator check to avoid incorrect bucketing.
                if let Some(creator_id) = self.resolve_candidate_creator_id(c) {
                    let already_delivered = self
                        .notification_tracker
                        .map_or(0, |t| t.count_today(user_id, creator_id, today));

                    let in_response = per_creator.get(&creator_id).copied().unwrap_or(0);
                    if already_delivered + in_response >= caps.max_per_creator_per_day {
                        return false;
                    }

                    *per_creator.entry(creator_id).or_insert(0) += 1;
                }

                in_response_total += 1;
                true
            })
            .collect()
    }

    /// Record notification deliveries for the items ACTUALLY returned to the
    /// caller (the post-pagination page), and drop any page item the tracker
    /// rejects under the now-live cap.
    ///
    /// Only the `notification` profile records into the shared tracker, because
    /// it is the surface whose daily cap drives whether a user is notified. The
    /// record goes through the tracker's atomic `check_and_record`, which
    /// evaluates both caps and increments under a single lock — so two concurrent
    /// responses for the same user cannot both observe headroom and both record,
    /// overshooting the caps (TOCTOU). Because this runs on the delivered page
    /// (not the full pre-slice set), the daily budget is consumed by exactly the
    /// notifications the caller receives — never the items trimmed away by
    /// pagination (C-CRITICAL).
    pub(super) fn record_notification_deliveries(
        &self,
        page: &[crate::ranking::executor::ScoredCandidate],
        caps: crate::query::retrieve::NotificationCaps,
        user_id: u64,
        profile_name: &str,
    ) -> Vec<crate::ranking::executor::ScoredCandidate> {
        if profile_name != "notification" {
            return page.to_vec();
        }
        let Some(tracker) = self.notification_tracker else {
            return page.to_vec();
        };

        let today = Self::today_days_since_epoch();
        page.iter()
            .filter(|c| {
                tracker.check_and_record(
                    user_id,
                    self.resolve_candidate_creator_id(c),
                    c.entity_id.as_u64(),
                    today,
                    caps.max_per_creator_per_day,
                    caps.max_total_per_day,
                )
            })
            .cloned()
            .collect()
    }
}

// ── Tests ────────────────────────────────────────────────────────────────────

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use std::{sync::RwLock, time::Duration};

    use roaring::RoaringBitmap;

    // `ScoredCandidate`, `EntityId`, and `QueryError` are already in scope via
    // `use super::*` (the module's own imports); only the test-only fixtures and
    // the `Boost`/`SignalAgg` profile types are new here.
    use super::super::test_support::{make_executor, setup_registry};
    use super::*;
    use crate::{
        cohort::CohortSignalLedger,
        ranking::profile::{
            Boost, CandidateStrategy, DiversitySpec, RankingProfile, SignalAgg, Sort,
        },
        schema::{DecaySpec, EntityKind, SchemaBuilder, Timestamp, Window},
        signals::{NoopWalWriter, SignalLedger},
        storage::indexes::{bitmap::BitmapIndex, range::RangeIndex},
    };

    /// Build a minimal `RankingProfile` carrying the given `boosts` and `sort`,
    /// so `rescore_with_cohort` (which now takes the whole profile to honor the
    /// sort mode) can be exercised directly.
    fn cohort_profile(boosts: Vec<Boost>, sort: Option<Sort>) -> RankingProfile {
        RankingProfile {
            name: "cohort_test".to_owned(),
            version: 1,
            candidate_strategy: CandidateStrategy::Scan {
                sort_field: String::new(),
            },
            boosts,
            decay: None,
            gates: vec![],
            penalties: vec![],
            excludes: vec![],
            diversity: DiversitySpec::default(),
            exploration: 0.0,
            sort,
            is_builtin: false,
        }
    }

    /// Schema with a single known signal (`view`) so the cohort ledger resolves
    /// `view` but rejects any other name with `UnknownSignalType`.
    fn cohort_schema() -> crate::schema::Schema {
        let mut builder = SchemaBuilder::new();
        let _ = builder
            .signal(
                "view",
                EntityKind::Item,
                DecaySpec::Exponential {
                    half_life: Duration::from_secs(7 * 24 * 3600),
                },
            )
            .windows(&[Window::AllTime])
            .add();
        builder.build().unwrap()
    }

    fn candidate(id: u64, score: f64) -> ScoredCandidate {
        ScoredCandidate {
            entity_id: EntityId::new(id),
            score,
            signal_snapshot: Vec::new(),
            creator_id: None,
            format: None,
        }
    }

    /// W26 unit: an unknown-signal schema error degrades to the neutral default
    /// (mirroring `read_agg_for_sort`); every other error propagates as a loud
    /// `InvalidFilter` instead of being swallowed by `unwrap_or`.
    #[test]
    fn degrade_unknown_signal_degrades_only_unknown_signal() {
        // Unknown signal → degrade to the supplied default.
        let unknown: crate::Result<u64> = Err(crate::TidalError::Schema(
            crate::schema::error::SchemaError::UnknownSignalType("ghost".into()),
        ));
        assert_eq!(degrade_unknown_signal(unknown, "ghost", 7).unwrap(), 7);

        // Any other error → propagate as InvalidFilter (NOT swallowed).
        let other: crate::Result<u64> = Err(crate::TidalError::internal("test", "boom"));
        let err = degrade_unknown_signal(other, "view", 0)
            .expect_err("non-unknown-signal error must propagate, not degrade");
        assert!(matches!(err, QueryError::InvalidFilter { .. }));

        // Ok value passes through unchanged.
        assert_eq!(degrade_unknown_signal(Ok(3u64), "view", 0).unwrap(), 3);
    }

    /// W26 integration: a cohort boost on an UNKNOWN signal must degrade to 0.0
    /// and let the rescore succeed (the cohort ledger holds no entries for it),
    /// rather than erroring — and must NOT crash. Before the fix the swallow was
    /// indiscriminate; this proves the unknown-signal path still degrades.
    #[test]
    fn rescore_with_cohort_degrades_unknown_signal() {
        let global = SignalLedger::new(cohort_schema(), Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let cat = BitmapIndex::new("category");
        let fmt = BitmapIndex::new("format");
        let cre = BitmapIndex::new("creator");
        let tag = BitmapIndex::new("tag");
        let dur = RangeIndex::<u32>::new("duration");
        let ts = RangeIndex::<u64>::new("created_at");
        let universe = RwLock::new(RoaringBitmap::new());
        let exec = make_executor(
            &global,
            &profile_reg,
            &cat,
            &fmt,
            &cre,
            &tag,
            &dur,
            &ts,
            &universe,
        );

        let cohort_ledger = CohortSignalLedger::new(&cohort_schema());
        let mut scored = vec![candidate(1, 0.9), candidate(2, 0.1)];

        // Boost references a signal NOT in the schema → unknown-signal degrade.
        let profile = cohort_profile(
            vec![Boost {
                signal: "not_a_signal".to_string(),
                agg: SignalAgg::Value,
                window: Window::AllTime,
                weight: 1.0,
            }],
            None,
        );

        // Must succeed (degrade to 0.0), not error.
        exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &profile)
            .expect("unknown cohort signal must degrade to 0.0, not error");
        // Every cohort score collapsed to 0.0 → an all-equal set normalizes to
        // 0.5 (spec §8.2: neutral / no signal), matching the ranking executor.
        assert!(scored.iter().all(|c| (c.score - 0.5).abs() < f64::EPSILON));
    }

    /// W26 integration: a known-signal cohort boost still scores normally,
    /// proving the degrade helper does not interfere with the happy path.
    #[test]
    fn rescore_with_cohort_scores_known_signal() {
        let global = SignalLedger::new(cohort_schema(), Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let cat = BitmapIndex::new("category");
        let fmt = BitmapIndex::new("format");
        let cre = BitmapIndex::new("creator");
        let tag = BitmapIndex::new("tag");
        let dur = RangeIndex::<u32>::new("duration");
        let ts = RangeIndex::<u64>::new("created_at");
        let universe = RwLock::new(RoaringBitmap::new());
        let exec = make_executor(
            &global,
            &profile_reg,
            &cat,
            &fmt,
            &cre,
            &tag,
            &dur,
            &ts,
            &universe,
        );

        let cohort_ledger = CohortSignalLedger::new(&cohort_schema());
        let type_id = cohort_ledger.resolve_signal_type("view").unwrap();
        let now_ns = Timestamp::now().as_nanos();
        // Item 2 gets cohort views; item 1 gets none → item 2 should rank first.
        let cohort: crate::cohort::CohortName = std::sync::Arc::from("tech");
        for _ in 0..5 {
            cohort_ledger.record(&cohort, EntityId::new(2), type_id, 1.0, now_ns);
        }

        let mut scored = vec![candidate(1, 0.9), candidate(2, 0.1)];
        let profile = cohort_profile(
            vec![Boost {
                signal: "view".to_string(),
                agg: SignalAgg::Value,
                window: Window::AllTime,
                weight: 1.0,
            }],
            None,
        );

        exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &profile)
            .expect("known signal must score without error");
        // The cohort rescore + re-sort puts the higher cohort-signal item first.
        assert_eq!(scored[0].entity_id, EntityId::new(2));
        // The reported snapshot reflects the cohort scope (5 views), not the stale
        // global snapshot the candidate carried in.
        let top = scored
            .iter()
            .find(|c| c.entity_id == EntityId::new(2))
            .expect("item 2 present");
        assert!(
            top.signal_snapshot
                .iter()
                .any(|(name, value)| name == "view" && (*value - 5.0).abs() < f64::EPSILON),
            "cohort snapshot must report the cohort-scoped view count, got {:?}",
            top.signal_snapshot
        );
    }

    /// C-CRITICAL regression: a cohort + a metadata/id-based sort profile (`new`,
    /// which scores by descending entity id with NO boosts) must PRESERVE the
    /// main-path ordering and scores. Before the fix the rescore overwrote every
    /// score with a cohort-boost sum of 0.0, normalize mapped them all to 1.0, and
    /// the descending re-sort was a no-op — destroying the New ordering and
    /// returning uniform scores. With the fix the boost-free profile is a no-op.
    #[test]
    fn rescore_with_cohort_preserves_new_sort_ordering() {
        let global = SignalLedger::new(cohort_schema(), Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let cat = BitmapIndex::new("category");
        let fmt = BitmapIndex::new("format");
        let cre = BitmapIndex::new("creator");
        let tag = BitmapIndex::new("tag");
        let dur = RangeIndex::<u32>::new("duration");
        let ts = RangeIndex::<u64>::new("created_at");
        let universe = RwLock::new(RoaringBitmap::new());
        let exec = make_executor(
            &global,
            &profile_reg,
            &cat,
            &fmt,
            &cre,
            &tag,
            &dur,
            &ts,
            &universe,
        );

        let cohort_ledger = CohortSignalLedger::new(&cohort_schema());
        // The main path for `New` scores by entity id; item 3 (highest id) ranks
        // first, then 2, then 1. The scores are distinct, not all-equal.
        let mut scored = vec![candidate(1, 1.0), candidate(2, 2.0), candidate(3, 3.0)];
        // `new`-style profile: no boosts, Sort::New.
        let profile = cohort_profile(vec![], Some(Sort::New));

        exec.rescore_with_cohort(&mut scored, "tech", &cohort_ledger, &profile)
            .expect("boost-free sort profile must not error");

        // Ordering and scores are untouched (rescore is a no-op for this shape).
        let ids: Vec<u64> = scored.iter().map(|c| c.entity_id.as_u64()).collect();
        assert_eq!(
            ids,
            vec![1, 2, 3],
            "the main-path order must be preserved verbatim, not re-sorted to uniform 1.0"
        );
        assert!(
            !scored.iter().all(|c| (c.score - 1.0).abs() < f64::EPSILON),
            "scores must NOT collapse to a uniform 1.0; got {:?}",
            scored.iter().map(|c| c.score).collect::<Vec<_>>()
        );
    }

    /// C-CRITICAL regression (notification cap recording follows pagination):
    /// the Stage 4.5 FILTER (`apply_notification_caps`) must record NOTHING, and
    /// recording must happen on the delivered PAGE via
    /// `record_notification_deliveries` — so a `limit < survivors` query records
    /// exactly `limit` deliveries, not every survivor. Before the fix the full
    /// post-diversity set was recorded, silently exhausting the daily cap with
    /// items the caller never received.
    #[test]
    fn notification_caps_record_only_the_delivered_page() {
        use std::sync::Arc;

        use crate::{
            db::notification_tracker::NotificationTracker, query::retrieve::NotificationCaps,
        };

        let global = SignalLedger::new(cohort_schema(), Box::new(NoopWalWriter));
        let profile_reg = setup_registry();
        let cat = BitmapIndex::new("category");
        let fmt = BitmapIndex::new("format");
        let cre = BitmapIndex::new("creator");
        let tag = BitmapIndex::new("tag");
        let dur = RangeIndex::<u32>::new("duration");
        let ts = RangeIndex::<u64>::new("created_at");
        let universe = RwLock::new(RoaringBitmap::new());
        let tracker = Arc::new(NotificationTracker::new());
        let exec = make_executor(
            &global,
            &profile_reg,
            &cat,
            &fmt,
            &cre,
            &tag,
            &dur,
            &ts,
            &universe,
        )
        .with_notification_tracker(&tracker);

        // Five survivors from distinct creators; generous caps so the FILTER keeps
        // all five.
        let mk = |id: u64, creator: u64| ScoredCandidate {
            entity_id: EntityId::new(id),
            score: 1.0,
            signal_snapshot: vec![],
            creator_id: Some(EntityId::new(creator)),
            format: None,
        };
        let candidates = vec![mk(1, 10), mk(2, 20), mk(3, 30), mk(4, 40), mk(5, 50)];
        let caps = NotificationCaps {
            max_per_creator_per_day: 5,
            max_total_per_day: 10,
        };

        // FILTER keeps all five and records NOTHING.
        let filtered = exec.apply_notification_caps(candidates, caps, 42, "notification");
        assert_eq!(
            filtered.len(),
            5,
            "filter keeps all five under generous caps"
        );

        let nanos_per_day: u64 = 86_400 * 1_000_000_000;
        let today = (Timestamp::now().as_nanos() / nanos_per_day) as u32;
        assert_eq!(
            tracker.count_total_today(42, today),
            0,
            "the filter pass must NOT record any deliveries"
        );

        // A page of limit=2 is recorded — exactly two deliveries, not five.
        let page = &filtered[..2];
        let delivered = exec.record_notification_deliveries(page, caps, 42, "notification");
        assert_eq!(delivered.len(), 2, "the delivered page is exactly the page");
        assert_eq!(
            tracker.count_total_today(42, today),
            2,
            "exactly `limit` (2) deliveries recorded — not all five survivors"
        );

        // Re-issuing the identical query (same page, same user, same day) must
        // NOT double-count the already-delivered items — the cap counts distinct
        // items notified about, not raw delivery attempts.
        let redelivered = exec.record_notification_deliveries(page, caps, 42, "notification");
        assert_eq!(redelivered.len(), 2, "the page still re-appears in full");
        assert_eq!(
            tracker.count_total_today(42, today),
            2,
            "a second identical query must not double-count delivered items"
        );

        // A non-notification profile records nothing even when given a page.
        let other = Arc::new(NotificationTracker::new());
        let other_exec = make_executor(
            &global,
            &profile_reg,
            &cat,
            &fmt,
            &cre,
            &tag,
            &dur,
            &ts,
            &universe,
        )
        .with_notification_tracker(&other);
        let _ = other_exec.record_notification_deliveries(page, caps, 42, "trending");
        assert_eq!(
            other.count_total_today(42, today),
            0,
            "only the notification profile records deliveries"
        );
    }
}
