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
582 lines
22 KiB
Rust
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"
|
|
));
|
|
}
|
|
}
|