tidaldb/tidal/src/ranking/executor/helpers.rs
jx12n 9728194f16 fix: M0-M10 code-review pass2 remediation — all 91 findings
Resolves every finding in docs/reviews/M0-M10-code-review-2026-06-08-pass2.md
across the engine, network, server, and CLI crates: session restore,
replication/CRDT, WAL format and recovery, storage indexes, query/ranking
executors, cohort/community governance, and scatter-gather routing.

Adds regression tests:
- review_pass2_creator_search_filter
- review_pass2_d_replication
- review_pass2_query_for_session
- review_pass2_storage_indexes_bitmap_cache
- review_pass2_zone_a_sessions

Verified: cargo clippy -D warnings and full test suite green across all crates.
2026-06-09 12:21:00 -06:00

494 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<f64> {
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<f64> {
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<bool> {
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<bool> {
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<ScoredCandidate> = (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"
);
}
}
}