//! 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 { 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>, /// 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, } 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" )); } }