tidaldb/tidal/src/ranking/registry.rs
jx12n b55ad70141 fix: M0-M10 third-pass remediation — durability, replication, and CLI hardening
Resolves the 142 findings from tidal/docs/reviews/CODE_REVIEW_m0-m10.md across
the engine, server, net, and CLI surfaces:

- WAL/session-journal durability, checkpoint format, and crash-recovery hardening
- Replication shipper/receiver, tenant isolation, and migration paths
- Cluster scatter-gather, router, standalone server + health/offload endpoints
- tidalctl refactored into command modules with JSON output and WAL-state tooling
- Cohort, governance, signal-ledger, and vector-registry correctness fixes
- Expanded UAT/integration/durability test coverage across all milestones
2026-06-08 10:28:34 -06:00

582 lines
22 KiB
Rust

//! Profile registry: versioned storage and retrieval of ranking profiles.
//!
//! Profiles are stored by name, with multiple versions per name kept in a
//! `BTreeMap`. The registry enforces:
//! - Name format: `^[a-z0-9_]{1,64}$` (validated without regex crate)
//! - Monotonically increasing versions per name
//! - Exploration in `[0.0, 0.5]`
//! - Gate thresholds: `[0.0, 1.0]` for `DecayScore`/`Ratio`, `>= 0.0` for others
//! - Every referenced aggregation is implemented (rejects `Ratio` /
//! `RelativeVelocity`, which the executor does not yet compute)
//! - When a schema is bound (see [`ProfileRegistry::with_schema`]), every
//! signal name referenced by a non-builtin profile resolves in that schema
use std::collections::{BTreeMap, HashMap};
use super::profile::{RankingProfile, SignalAgg};
use crate::schema::Schema;
// ── Error ───────────────────────────────────────────────────────────────────
/// Errors from profile registration and lookup.
#[derive(Debug, thiserror::Error)]
pub enum ProfileError {
#[error("invalid profile name '{0}': must match [a-z0-9_]{{1,64}}")]
InvalidName(String),
#[error(
"version conflict for profile '{name}': new version {new} must be > existing {existing}"
)]
VersionConflict {
name: String,
existing: u32,
new: u32,
},
#[error("exploration {0} out of range [0.0, 0.5]")]
ExplorationOutOfRange(f64),
#[error("gate threshold {0} out of range (DecayScore/Ratio: [0.0, 1.0]; others: >= 0.0)")]
GateThresholdOutOfRange(f64),
#[error("profile '{profile}' references signal '{signal}' which is not defined in the schema")]
UnknownSignalType { signal: String, profile: String },
#[error(
"profile '{profile}' uses aggregation '{agg}' which the executor does not implement \
(Ratio / RelativeVelocity are reserved for a future milestone)"
)]
UnsupportedAggregation { agg: String, profile: String },
#[error("profile '{0}' not found")]
NotFound(String),
#[error("profile '{name}' version {version} not found")]
VersionNotFound { name: String, version: u32 },
}
// ── Validation ──────────────────────────────────────────────────────────────
/// Validate profile name without the regex crate.
///
/// Accepts `^[a-z0-9_]{1,64}$` -- lowercase ASCII alphanumeric plus underscore,
/// 1 to 64 characters.
fn is_valid_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 64
&& name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
/// Yield every `(signal name, aggregation)` term a profile reads from the
/// signal ledger: gates, boosts, penalties, and excludes. Sort modes read
/// fixed, executor-internal signals (`view`, `like`, ...) and are not
/// user-declared signal references, so they are not validated here.
fn profile_signal_terms(profile: &RankingProfile) -> impl Iterator<Item = (&str, &SignalAgg)> {
profile
.gates
.iter()
.map(|g| (g.signal.as_str(), &g.agg))
.chain(profile.boosts.iter().map(|b| (b.signal.as_str(), &b.agg)))
.chain(
profile
.penalties
.iter()
.map(|p| (p.signal.as_str(), &p.agg)),
)
.chain(profile.excludes.iter().map(|e| (e.signal.as_str(), &e.agg)))
}
// ── Registry ────────────────────────────────────────────────────────────────
/// Versioned registry of ranking profiles.
///
/// Stores `name -> version -> profile`. Lookups return the latest version by
/// default. Registration validates name format, version monotonicity,
/// exploration bounds, gate thresholds, aggregation support, and -- when a
/// schema is bound -- signal-name resolution.
pub struct ProfileRegistry {
/// name -> version -> profile
profiles: HashMap<String, BTreeMap<u32, RankingProfile>>,
/// Schema used to validate that signal names referenced by non-builtin
/// profiles actually exist. `None` until [`Self::with_schema`] is called;
/// while it is `None`, signal-name resolution is skipped entirely. Built-in
/// profiles are always exempt -- they reference a superset of any individual
/// schema's signals by design.
schema: Option<Schema>,
}
impl ProfileRegistry {
#[must_use]
pub fn new() -> Self {
Self {
profiles: HashMap::new(),
schema: None,
}
}
/// Bind a schema so subsequent registrations validate signal-name
/// resolution for non-builtin profiles.
///
/// Built-in profiles ([`RankingProfile::is_builtin`]) are exempt: they
/// reference the full generic signal vocabulary (`view`, `like`, `share`,
/// ...) and a given application schema legitimately defines only a subset.
/// Bind the schema *after* registering builtins and *before* registering
/// schema-defined profiles via [`Self::override_register`].
pub fn with_schema(&mut self, schema: Schema) {
self.schema = Some(schema);
}
/// Validate the parts of a profile that are independent of version /
/// override semantics: name format, exploration bounds, gate thresholds,
/// aggregation support, and (when a schema is bound) signal-name
/// resolution.
fn validate_profile(&self, profile: &RankingProfile) -> Result<(), ProfileError> {
if !is_valid_name(&profile.name) {
return Err(ProfileError::InvalidName(profile.name.clone()));
}
if !(0.0..=0.5).contains(&profile.exploration) {
return Err(ProfileError::ExplorationOutOfRange(profile.exploration));
}
// Only naturally normalized aggregations must be in [0, 1].
// Value / Velocity / RelativeVelocity aggregations represent raw counts and
// may have thresholds above 1.0.
for gate in &profile.gates {
if matches!(gate.agg, SignalAgg::DecayScore | SignalAgg::Ratio) {
if !(0.0..=1.0).contains(&gate.min_threshold) {
return Err(ProfileError::GateThresholdOutOfRange(gate.min_threshold));
}
} else if gate.min_threshold < 0.0 {
return Err(ProfileError::GateThresholdOutOfRange(gate.min_threshold));
}
}
// Every signal-bearing term must reference an implemented aggregation
// and (when a schema is bound, for non-builtin profiles) a signal that
// resolves in the schema. A typo'd signal name or an unimplemented
// aggregation would otherwise register cleanly and then silently
// exclude every candidate at query time.
for (signal, agg) in profile_signal_terms(profile) {
if !agg.is_implemented() {
return Err(ProfileError::UnsupportedAggregation {
agg: agg.label().to_owned(),
profile: profile.name.clone(),
});
}
if !profile.is_builtin
&& let Some(schema) = &self.schema
&& schema.signal(signal).is_none()
{
return Err(ProfileError::UnknownSignalType {
signal: signal.to_owned(),
profile: profile.name.clone(),
});
}
}
Ok(())
}
/// Register a profile. Validates all constraints before insertion.
///
/// # Errors
///
/// - `InvalidName` if name does not match `[a-z0-9_]{1,64}`
/// - `ExplorationOutOfRange` if exploration not in `[0.0, 0.5]`
/// - `GateThresholdOutOfRange` if a `DecayScore`/`Ratio` gate is not in `[0.0, 1.0]`, or any other gate is negative
/// - `VersionConflict` if version <= max existing version for this name
/// - `UnsupportedAggregation` if any term uses `Ratio` / `RelativeVelocity`
/// - `UnknownSignalType` if a schema is bound and a non-builtin profile
/// references a signal not in that schema
pub fn register(&mut self, profile: RankingProfile) -> Result<(), ProfileError> {
self.validate_profile(&profile)?;
let versions = self.profiles.entry(profile.name.clone()).or_default();
if let Some((&max_version, _)) = versions.last_key_value()
&& profile.version <= max_version
{
return Err(ProfileError::VersionConflict {
name: profile.name,
existing: max_version,
new: profile.version,
});
}
versions.insert(profile.version, profile);
Ok(())
}
/// Override-register a profile, replacing all existing versions for that name.
///
/// Clears any previously registered versions (including builtins) and inserts
/// the new profile as the sole version. Applies the same validation as
/// [`register`](Self::register) (name format, exploration bounds, gate
/// thresholds, aggregation support, schema signal-name resolution) but
/// skips version monotonicity since all prior versions are removed.
///
/// This is used by schema-defined profiles to cleanly replace built-in
/// defaults without version gymnastics.
///
/// # Errors
///
/// Same as [`register`](Self::register) except `VersionConflict` is impossible.
pub fn override_register(&mut self, profile: RankingProfile) -> Result<(), ProfileError> {
self.validate_profile(&profile)?;
// Clear all existing versions for this name, then insert the new one.
let versions = self.profiles.entry(profile.name.clone()).or_default();
versions.clear();
versions.insert(profile.version, profile);
Ok(())
}
/// Get the latest version of a named profile.
///
/// # Errors
///
/// Returns `NotFound` if no profile with the given name exists.
pub fn get(&self, name: &str) -> Result<&RankingProfile, ProfileError> {
self.profiles
.get(name)
.and_then(|versions| versions.values().next_back())
.ok_or_else(|| ProfileError::NotFound(name.to_owned()))
}
/// Get a specific version of a named profile.
///
/// # Errors
///
/// Returns `NotFound` if the name does not exist, or `VersionNotFound` if
/// the specific version is not registered.
pub fn get_version(&self, name: &str, version: u32) -> Result<&RankingProfile, ProfileError> {
let versions = self
.profiles
.get(name)
.ok_or_else(|| ProfileError::NotFound(name.to_owned()))?;
versions
.get(&version)
.ok_or_else(|| ProfileError::VersionNotFound {
name: name.to_owned(),
version,
})
}
/// List the latest version of every registered profile.
///
/// Results are sorted alphabetically by profile name.
#[must_use]
pub fn list(&self) -> Vec<&RankingProfile> {
let mut result: Vec<&RankingProfile> = self
.profiles
.values()
.filter_map(|versions| versions.values().next_back())
.collect();
result.sort_by(|a, b| a.name.cmp(&b.name));
result
}
}
impl Default for ProfileRegistry {
fn default() -> Self {
Self::new()
}
}
// ── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use std::time::Duration;
use super::*;
use crate::{
ranking::{
profile::{Boost, Gate, SignalAgg},
// Shared profile skeleton (DRY-S, M0-M10 review): the per-module
// `minimal_profile(name, version)` literal now lives in
// `ranking::test_fixtures` as `minimal_profile_versioned`.
test_fixtures::minimal_profile_versioned as minimal_profile,
},
schema::{DecaySpec, EntityKind, SchemaBuilder, Window},
};
/// Build a single-signal schema for signal-name resolution tests.
fn schema_with(signal: &str) -> Schema {
let mut builder = SchemaBuilder::new();
let _ = builder
.signal(
signal,
EntityKind::Item,
DecaySpec::Exponential {
half_life: Duration::from_secs(3600),
},
)
.windows(&[Window::ThirtyDays, Window::AllTime])
.velocity(false)
.add();
builder.build().unwrap()
}
#[test]
fn register_and_get() {
let mut registry = ProfileRegistry::new();
registry.register(minimal_profile("trending", 1)).unwrap();
let profile = registry.get("trending").unwrap();
assert_eq!(profile.name, "trending");
}
#[test]
fn version_monotonicity() {
let mut registry = ProfileRegistry::new();
registry.register(minimal_profile("trending", 1)).unwrap();
let result = registry.register(minimal_profile("trending", 1));
assert!(matches!(result, Err(ProfileError::VersionConflict { .. })));
}
#[test]
fn invalid_name_rejected() {
let mut registry = ProfileRegistry::new();
let result = registry.register(minimal_profile("has spaces", 1));
assert!(matches!(result, Err(ProfileError::InvalidName(_))));
}
#[test]
fn exploration_out_of_range() {
let mut registry = ProfileRegistry::new();
let mut profile = minimal_profile("explore", 1);
profile.exploration = 0.9;
let result = registry.register(profile);
assert!(matches!(
result,
Err(ProfileError::ExplorationOutOfRange(_))
));
}
#[test]
fn gate_threshold_out_of_range_decay_score() {
let mut registry = ProfileRegistry::new();
let mut profile = minimal_profile("gated", 1);
profile.gates.push(Gate {
signal: "view".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
min_threshold: 1.5,
});
let result = registry.register(profile);
assert!(matches!(
result,
Err(ProfileError::GateThresholdOutOfRange(_))
));
}
#[test]
fn gate_threshold_value_above_one_allowed() {
let mut registry = ProfileRegistry::new();
let mut profile = minimal_profile("gated_value", 1);
profile.gates.push(Gate {
signal: "view".into(),
agg: SignalAgg::Value,
window: Window::AllTime,
min_threshold: 100.0,
});
let result = registry.register(profile);
assert!(
result.is_ok(),
"Value gate with threshold > 1.0 should be allowed"
);
}
#[test]
fn gate_threshold_negative_rejected() {
let mut registry = ProfileRegistry::new();
let mut profile = minimal_profile("gated_neg", 1);
profile.gates.push(Gate {
signal: "view".into(),
agg: SignalAgg::Value,
window: Window::AllTime,
min_threshold: -1.0,
});
let result = registry.register(profile);
assert!(matches!(
result,
Err(ProfileError::GateThresholdOutOfRange(_))
));
}
#[test]
fn get_not_found() {
let registry = ProfileRegistry::new();
let result = registry.get("nonexistent");
assert!(matches!(result, Err(ProfileError::NotFound(_))));
}
#[test]
fn override_register_replaces_builtin() {
let mut registry = ProfileRegistry::new();
let mut builtin = minimal_profile("for_you", 1);
builtin.is_builtin = true;
registry.register(builtin).unwrap();
// Override with a schema-defined version.
let mut override_profile = minimal_profile("for_you", 1);
override_profile.exploration = 0.1;
registry.override_register(override_profile).unwrap();
let profile = registry.get("for_you").unwrap();
assert!(!profile.is_builtin);
assert!((profile.exploration - 0.1).abs() < f64::EPSILON);
}
#[test]
fn override_register_clears_all_versions() {
let mut registry = ProfileRegistry::new();
registry.register(minimal_profile("trending", 1)).unwrap();
registry.register(minimal_profile("trending", 2)).unwrap();
// Override clears both v1 and v2.
let override_profile = minimal_profile("trending", 1);
registry.override_register(override_profile).unwrap();
// Only v1 exists now.
assert!(registry.get_version("trending", 2).is_err());
assert!(registry.get_version("trending", 1).is_ok());
}
#[test]
fn override_register_validates_constraints() {
let mut registry = ProfileRegistry::new();
// Invalid name.
let result = registry.override_register(minimal_profile("BAD NAME", 1));
assert!(matches!(result, Err(ProfileError::InvalidName(_))));
// Exploration out of range.
let mut profile = minimal_profile("bad_explore", 1);
profile.exploration = 0.9;
let result = registry.override_register(profile);
assert!(matches!(
result,
Err(ProfileError::ExplorationOutOfRange(_))
));
// Gate threshold out of range.
let mut profile = minimal_profile("bad_gate", 1);
profile.gates.push(Gate {
signal: "view".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
min_threshold: 1.5,
});
let result = registry.override_register(profile);
assert!(matches!(
result,
Err(ProfileError::GateThresholdOutOfRange(_))
));
}
#[test]
fn register_rejects_ratio_aggregation_in_gate() {
let mut registry = ProfileRegistry::new();
let mut profile = minimal_profile("ratio_gate", 1);
profile.gates.push(Gate {
signal: "view".into(),
agg: SignalAgg::Ratio,
window: Window::AllTime,
min_threshold: 0.5,
});
let result = registry.register(profile);
assert!(matches!(
result,
Err(ProfileError::UnsupportedAggregation { ref agg, .. }) if agg == "Ratio"
));
}
#[test]
fn register_rejects_relative_velocity_aggregation_in_boost() {
let mut registry = ProfileRegistry::new();
let mut profile = minimal_profile("rel_vel_boost", 1);
profile.boosts.push(Boost {
signal: "view".into(),
agg: SignalAgg::RelativeVelocity,
window: Window::AllTime,
weight: 1.0,
});
let result = registry.register(profile);
assert!(matches!(
result,
Err(ProfileError::UnsupportedAggregation { ref agg, .. }) if agg == "RelativeVelocity"
));
}
#[test]
fn register_rejects_unknown_signal_when_schema_bound() {
let mut registry = ProfileRegistry::new();
registry.with_schema(schema_with("digest_open"));
let mut profile = minimal_profile("typo", 1);
profile.boosts.push(Boost {
// Typo'd signal name -- not in the bound schema.
signal: "digest_opne".into(),
agg: SignalAgg::DecayScore,
window: Window::ThirtyDays,
weight: 1.0,
});
let result = registry.register(profile);
assert!(matches!(
result,
Err(ProfileError::UnknownSignalType { ref signal, .. }) if signal == "digest_opne"
));
}
#[test]
fn register_accepts_known_signal_and_thirty_days_window_when_schema_bound() {
let mut registry = ProfileRegistry::new();
registry.with_schema(schema_with("digest_open"));
let mut profile = minimal_profile("good", 1);
profile.boosts.push(Boost {
signal: "digest_open".into(),
agg: SignalAgg::DecayScore,
window: Window::ThirtyDays,
weight: 1.0,
});
// Known signal + ThirtyDays window registers cleanly (mirrors the
// built-in `for_you` profile shape).
assert!(registry.register(profile).is_ok());
}
#[test]
fn register_exempts_builtin_from_schema_signal_resolution() {
let mut registry = ProfileRegistry::new();
registry.with_schema(schema_with("digest_open"));
// A builtin references the generic vocabulary (`view`) which this
// schema does not define; builtins are exempt from name resolution.
let mut builtin = minimal_profile("trending", 1);
builtin.is_builtin = true;
builtin.boosts.push(Boost {
signal: "view".into(),
agg: SignalAgg::Velocity,
window: Window::AllTime,
weight: 1.0,
});
assert!(registry.register(builtin).is_ok());
}
#[test]
fn override_register_rejects_unknown_signal_when_schema_bound() {
let mut registry = ProfileRegistry::new();
registry.with_schema(schema_with("digest_open"));
let mut profile = minimal_profile("for_you", 1);
profile.boosts.push(Boost {
signal: "not_a_signal".into(),
agg: SignalAgg::DecayScore,
window: Window::AllTime,
weight: 1.0,
});
let result = registry.override_register(profile);
assert!(matches!(
result,
Err(ProfileError::UnknownSignalType { ref signal, .. }) if signal == "not_a_signal"
));
}
}