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.
494 lines
20 KiB
Rust
494 lines
20 KiB
Rust
//! 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"
|
||
);
|
||
}
|
||
}
|
||
}
|