//! Stateless helpers that bridge the signal ledger to the scoring pipeline. //! //! These functions read aggregations from the ledger and apply gate/normalization //! logic. They depend on `ScoredCandidate` (from `context`) and profile types, //! but contain no executor state. use super::context::ScoredCandidate; use crate::{ load::DegradationLevel, ranking::profile::{Exclude, Gate, SignalAgg}, schema::{EntityId, Window}, signals::SignalLedger, }; // -- Degradation window substitution ------------------------------------------ /// Coarsest velocity bucket used when `DegradationLevel::coarsens_aggregates()`. /// /// Under coarse-aggregate degradation, a `SignalAgg::Velocity` read is forced to /// this window (the coarsest velocity bucket) to cut fine-grained ledger scan /// cost. Exposed at module scope so the social-graph trending path in /// [`super::scoring`] -- which reads velocity directly off the per-user signal /// index rather than through [`read_agg`] -- can read the *same* window and stay /// in lockstep with the substitution rule here. pub(super) const COARSE_VELOCITY_WINDOW: Window = Window::TwentyFourHours; /// Coarsest count bucket used when `DegradationLevel::coarsens_aggregates()`. /// /// Under coarse-aggregate degradation, a `SignalAgg::Value` read is forced to the /// warm-tier all-time count (O(1)) instead of a fine-grained windowed scan. pub(super) const COARSE_VALUE_WINDOW: Window = Window::AllTime; // -- Signal reading ----------------------------------------------------------- /// Read a signal aggregation for a candidate against an explicit query clock. /// /// Propagates the ledger's schema error when `signal` is not defined, so a /// profile that slipped a typo'd signal name past registration fails loud /// instead of silently scoring 0.0. Missing *data* (no recorded signals) is /// not an error -- the ledger returns 0 / 0.0 / `None` for those, which we map /// to 0.0. /// /// `now_ns` is the query's logical clock, captured **once** per query by the /// executor and threaded into every ledger read so the whole candidate set is /// decayed/aged to one consistent "now". Without it each candidate would read /// `Timestamp::now()` independently and re-running the identical query against /// an unchanged ledger would yield slightly different scores (Accuracy-W, /// M0-M10 review): all the time-dependent reads here route through the ledger's /// explicit-clock `_at` variants. /// /// Under `DegradationLevel::CoarseAggregates` (or `NoDiversity`), windows are /// substituted to reduce ledger scan cost: /// - `SignalAgg::Value` → `Window::AllTime` (warm-tier count, O(1)) /// - `SignalAgg::Velocity` → `Window::TwentyFourHours` (coarsest velocity bucket) /// /// # Errors /// /// - `TidalError::Schema(UnknownSignalType)` if `signal` is not in the schema. /// - `TidalError::Internal` if `agg` is `Ratio` / `RelativeVelocity`: the /// registry rejects those at profile registration, so reaching this arm is a /// tidalDB invariant violation, not a recoverable runtime condition. pub(crate) fn read_agg( entity_id: EntityId, signal: &str, agg: &SignalAgg, window: Window, ledger: &SignalLedger, degradation: DegradationLevel, now_ns: u64, ) -> crate::Result { let window = if degradation.coarsens_aggregates() { match agg { SignalAgg::Value => COARSE_VALUE_WINDOW, SignalAgg::Velocity => COARSE_VELOCITY_WINDOW, _ => window, } } else { window }; match agg { SignalAgg::Value => { #[allow(clippy::cast_precision_loss)] let count = ledger.read_windowed_count_at(entity_id, signal, window, now_ns)? as f64; Ok(count) } SignalAgg::Velocity => ledger.read_velocity_at(entity_id, signal, window, now_ns), // `DecayScore` intentionally ignores `window`. A decay score is an O(1) // running quantity -- the exponentially-weighted sum maintained in the // hot tier (`HotSignalState`) -- decayed forward from its last update to // the read time. It is not derived from a windowed bucket scan, so there // is no per-window decay score to select: the half-life (encoded in the // signal's decay rate, here index 0) fully determines how the score ages, // not the `Window` argument. The argument is accepted only so every // aggregation arm shares one signature; passing different windows for a // `DecayScore` term yields the same value by construction. SignalAgg::DecayScore => Ok(ledger .read_decay_score_at(entity_id, signal, 0, now_ns)? .unwrap_or(0.0)), SignalAgg::Ratio | SignalAgg::RelativeVelocity => { // Not yet implemented -- planned for M3 when cross-signal reads are // available. The profile registry rejects these aggregations up // front (`ProfileError::UnsupportedAggregation`), so reaching this // arm means a profile bypassed validation: surface it as an // internal invariant violation rather than silently scoring 0.0. Err(crate::TidalError::internal( "read_agg", format!("aggregation '{}' is not implemented", agg.label()), )) } } } /// Read a fixed sort-mode signal, treating a signal the schema does not define /// as 0.0 rather than an error. /// /// Sort modes (`Hot`, `Trending`, `MostViewed`, ...) read a fixed generic /// signal vocabulary (`view`, `like`, `share`, `completion`, ...). Unlike a /// profile's user-declared gate/boost terms -- which the registry validates /// against the schema and which therefore fail loud here -- these sort-mode /// reads are optional infrastructure: any given application schema legitimately /// defines only a subset of the generic vocabulary (e.g. a consumer's schema /// has no `view` signal). A missing signal contributes 0.0 to the sort score; every /// other error still propagates. /// /// `now_ns` is the query clock threaded by the executor (see [`read_agg`]). /// /// # Errors /// /// Propagates any non-`UnknownSignalType` [`read_agg`] error (notably the /// `Ratio` / `RelativeVelocity` invariant violation). pub(super) fn read_agg_for_sort( entity_id: EntityId, signal: &str, agg: &SignalAgg, window: Window, ledger: &SignalLedger, degradation: DegradationLevel, now_ns: u64, ) -> crate::Result { match read_agg(entity_id, signal, agg, window, ledger, degradation, now_ns) { Err(crate::TidalError::Schema(crate::schema::error::SchemaError::UnknownSignalType(_))) => { Ok(0.0) } other => other, } } // -- Gate checking ------------------------------------------------------------ /// Check whether a candidate passes all gate thresholds. /// /// Gates are correctness filters -- always use `Full` degradation so that /// window precision is never sacrificed for gate evaluation. `now_ns` is the /// query clock threaded by the executor (see [`read_agg`]) so the gate decision /// ages every candidate to the same "now" as the score. /// /// # Errors /// /// Propagates any [`read_agg`] error (unknown signal / unsupported /// aggregation), so a misconfigured gate fails the whole query loudly instead /// of silently excluding every candidate. pub(super) fn passes_gates( entity_id: EntityId, gates: &[Gate], ledger: &SignalLedger, now_ns: u64, ) -> crate::Result { for gate in gates { let value = read_agg( entity_id, &gate.signal, &gate.agg, gate.window, ledger, DegradationLevel::Full, now_ns, )?; if value < gate.min_threshold { return Ok(false); } } Ok(true) } /// Check whether a candidate survives all hard-exclusion rules. /// /// Implements spec 09-ranking-scoring.md Stage 2 (Hard Exclusion). A candidate /// is *removed* (returns `false`) the moment any `Exclude` term's aggregation /// strictly exceeds its `above` threshold -- e.g. `Exclude { signal: "hide", /// above: 0.0 }` drops any item the user has hidden at all. This mirrors /// [`passes_gates`] (a hard filter, not a soft score adjustment) but inverts the /// comparison: gates demand a *minimum*, excludes impose a *maximum*. /// /// Like gates, excludes are correctness filters: they always read at /// [`DegradationLevel::Full`] so window precision is never traded away while /// deciding whether content the user asked to never see can appear. `now_ns` is /// the query clock threaded by the executor (see [`read_agg`]) so the exclusion /// decision ages every candidate to the same "now" as the score. /// /// # Errors /// /// Propagates any [`read_agg`] error (unknown signal / unsupported aggregation), /// so a misconfigured exclude fails the whole query loudly instead of silently /// admitting content that should have been removed. pub(super) fn passes_excludes( entity_id: EntityId, excludes: &[Exclude], ledger: &SignalLedger, now_ns: u64, ) -> crate::Result { for exclude in excludes { let value = read_agg( entity_id, &exclude.signal, &exclude.agg, exclude.window, ledger, DegradationLevel::Full, now_ns, )?; if value > exclude.above { return Ok(false); } } Ok(true) } // -- Normalization ------------------------------------------------------------ /// Min-max normalize candidate scores to `[0.0, 1.0]`. /// /// If all (finite) candidates have the same score (`max_score == min_score`), /// they are all set to **0.5** per spec 09-ranking-scoring.md §8.2 -- the /// neutral "no signal differentiates these" midpoint, not the maximally- /// confident `1.0`. Ordering is unaffected (every score is equal); the reported /// `score` field is what callers surface for explainability/thresholding, so a /// degenerate all-equal set must read as neutral rather than top-confidence. /// /// # Infinite "sort last / first" sentinels /// /// Several sort modes emit `f64::NEG_INFINITY` (or `f64::INFINITY`) as a /// deliberate "sort last / first" sentinel for missing metadata (`Shortest`, /// `Longest`, `DateSaved`). `f64::total_cmp` (used by `finalize`'s sort) already /// ranks those infinities at the correct extreme, so the *order* is right by the /// time we get here. Normalization must keep `score` **monotonic with that rank**. /// /// The min/max range is therefore computed over the **finite** scores only, and /// the infinite sentinels are mapped directly to the matching end of the output /// range afterwards: `NEG_INFINITY -> 0.0` (rank-last bottom), `INFINITY -> 1.0` /// (rank-first top). This fixes the anomaly where clamping `NEG_INFINITY -> 0.0` /// *before* taking the min let a missing item become the maximum on a negated /// scale: for `Shortest`, every present item has a *negative* score (`-duration`), /// so a pre-clamped `0.0` floated above them and the last-ranked (missing) item /// reported the highest score `1.0`. Computing the range over finite scores and /// folding sentinels to the correct end keeps the reported score consistent with /// the rank for the whole class of `±Inf`-sentinel sorts. /// /// `NaN` has no defined order and is neutralized to `0.0` at score *construction* /// (`finite_score`) before it can reach the sort, so it does not appear here; any /// `NaN` that slipped through is treated as a finite `0.0` for robustness. pub(super) fn normalize(candidates: &mut [ScoredCandidate]) { if candidates.is_empty() { return; } // Defensive: a stray NaN (which has no order) is mapped to the neutral 0.0, // matching `finite_score`. `±Inf` sentinels are intentionally preserved -- // they are folded to the correct end of the output range below. for c in candidates.iter_mut() { if c.score.is_nan() { c.score = 0.0; } } // Range is measured over FINITE scores only, so an infinite sentinel never // skews min/max (and so never becomes the artificial extreme of the range). let mut min = f64::INFINITY; let mut max = f64::NEG_INFINITY; for c in candidates.iter() { if c.score.is_finite() { min = min.min(c.score); max = max.max(c.score); } } // No finite scores at all (every candidate is a ±Inf sentinel): fold each to // its end of the range directly. With no finite reference, NEG_INFINITY -> 0, // INFINITY -> 1. (`is_sign_positive()` distinguishes the two infinities.) if !min.is_finite() { for c in candidates.iter_mut() { c.score = if c.score.is_sign_positive() { 1.0 } else { 0.0 }; } return; } let range = max - min; for c in candidates.iter_mut() { c.score = if c.score.is_infinite() { // Sentinel folds to the matching end of the output range: // +Inf "sort first" -> top (1.0); -Inf "sort last" -> bottom (0.0). if c.score.is_sign_positive() { 1.0 } else { 0.0 } } else if range < f64::EPSILON { // All candidates scored equally: neutral midpoint per spec §8.2. 0.5 } else { (c.score - min) / range }; } } // -- Tests -------------------------------------------------------------------- #[cfg(test)] #[allow(clippy::unwrap_used, clippy::float_cmp, clippy::cast_precision_loss)] mod tests { use super::*; use crate::{ // Shared ledger fixtures (DRY-S, M0-M10 review). ranking::test_fixtures::{ledger_for, seeded_ledger}, schema::Timestamp, }; #[test] fn read_agg_unimplemented_aggregation_errors() { let ledger = seeded_ledger(); let entity_id = EntityId::new(1); // SignalAgg::Ratio is not implemented; reaching read_agg with it is an // internal invariant violation (registration rejects it), so it errors // rather than silently scoring 0.0. let result = read_agg( entity_id, "view", &SignalAgg::Ratio, Window::AllTime, &ledger, DegradationLevel::Full, Timestamp::now().as_nanos(), ); assert!(matches!(result, Err(crate::TidalError::Internal(_)))); } #[test] fn read_agg_unknown_signal_errors() { let ledger = ledger_for(&["view"]); // "nonexistent" is not in the schema -> schema error propagates. let result = read_agg( EntityId::new(1), "nonexistent", &SignalAgg::Value, Window::OneHour, &ledger, DegradationLevel::Full, Timestamp::now().as_nanos(), ); assert!(matches!(result, Err(crate::TidalError::Schema(_)))); } #[test] fn passes_gates_below_threshold_excluded() { let ledger = ledger_for(&["view"]); let entity_id = EntityId::new(1); // Record 3 signals; windowed count = 3, below threshold of 5. for _ in 0..3 { ledger .record_signal("view", entity_id, 1.0, Timestamp::now()) .unwrap(); } let gates = vec![Gate { signal: "view".into(), agg: SignalAgg::Value, window: Window::OneHour, min_threshold: 5.0, }]; // count=3 < threshold=5 -> candidate excluded. assert!(!passes_gates(entity_id, &gates, &ledger, Timestamp::now().as_nanos()).unwrap()); } #[test] fn passes_gates_at_threshold_included() { let ledger = ledger_for(&["view"]); let entity_id = EntityId::new(1); // Record exactly 5 signals; windowed count = 5, meets threshold exactly. for _ in 0..5 { ledger .record_signal("view", entity_id, 1.0, Timestamp::now()) .unwrap(); } let gates = vec![Gate { signal: "view".into(), agg: SignalAgg::Value, window: Window::OneHour, min_threshold: 5.0, }]; // count=5 >= threshold=5 -> candidate included. assert!(passes_gates(entity_id, &gates, &ledger, Timestamp::now().as_nanos()).unwrap()); } #[test] fn normalize_single_element() { let mut candidates = vec![ScoredCandidate { entity_id: EntityId::new(1), score: 42.0, signal_snapshot: vec![], creator_id: None, format: None, }]; normalize(&mut candidates); // A single candidate is a degenerate all-equal set (range == 0), so it // normalizes to the neutral midpoint 0.5 per spec §8.2, not 1.0. assert_eq!(candidates[0].score, 0.5); } fn candidate(id: u64, score: f64) -> ScoredCandidate { ScoredCandidate { entity_id: EntityId::new(id), score, signal_snapshot: vec![], creator_id: None, format: None, } } #[test] fn normalize_neg_inf_sentinel_on_negated_scale_folds_to_bottom() { // Regression (Accuracy-W): `Shortest` scores present items as `-duration` // (all negative) and a missing item as NEG_INFINITY. The candidates arrive // already sorted descending: -60 (60s, shortest), -120 (120s), -Inf // (missing). Before the fix, NEG_INFINITY was clamped to 0.0 *before* // taking the min, so it became the maximum on the negated scale and // normalized to 1.0 -- the last-ranked item reporting the highest score. let mut candidates = vec![ candidate(3, -60.0), candidate(1, -120.0), candidate(2, f64::NEG_INFINITY), ]; normalize(&mut candidates); // Finite range is [-120, -60]; the shortest (−60) maps to 1.0, the 120s // (−120) to 0.0, and the missing sentinel folds to the bottom (0.0). assert_eq!(candidates[0].score, 1.0); assert_eq!(candidates[1].score, 0.0); assert_eq!(candidates[2].score, 0.0); // Score stays monotonic with rank -- the tail never outscores the head. assert!(candidates.last().unwrap().score <= candidates.first().unwrap().score); for w in candidates.windows(2) { assert!(w[0].score >= w[1].score); } } #[test] fn normalize_pos_inf_sentinel_folds_to_top() { // The `+Inf` "sort first" sentinel folds to the top of the output range, // while finite scores are min-max normalized over their own range. let mut candidates = vec![ candidate(1, f64::INFINITY), candidate(2, 10.0), candidate(3, 2.0), ]; normalize(&mut candidates); assert_eq!(candidates[0].score, 1.0); // +Inf -> top assert_eq!(candidates[1].score, 1.0); // max finite -> top of finite range assert_eq!(candidates[2].score, 0.0); // min finite -> bottom } #[test] fn normalize_all_infinite_sentinels_fold_to_ends() { // No finite scores at all: each sentinel folds directly to its end. let mut candidates = vec![candidate(1, f64::INFINITY), candidate(2, f64::NEG_INFINITY)]; normalize(&mut candidates); assert_eq!(candidates[0].score, 1.0); assert_eq!(candidates[1].score, 0.0); } #[test] fn normalize_all_nan_folds_to_neutral_midpoint() { // Every NaN is neutralized to 0.0 first, making this a degenerate // all-equal set (range == 0), which normalizes to the neutral 0.5 per // spec §8.2 -- not the maximally-confident 1.0. let mut candidates: Vec = (1u64..=3) .map(|i| ScoredCandidate { entity_id: EntityId::new(i), score: f64::NAN, signal_snapshot: vec![], creator_id: None, format: None, }) .collect(); normalize(&mut candidates); for c in &candidates { assert_eq!( c.score, 0.5, "an all-NaN (all-equal) set should fold to the neutral 0.5 midpoint" ); } } }