# Task 01: Ranking Profile Type System

## Context

**Milestone:** 2 -- Ranked Retrieval
**Phase:** m2p3 -- Ranking Profile Engine
**Depends On:** None (uses types from m1p1 but no m2p3 tasks)
**Blocks:** Task 02 (Built-in Profiles), Task 03 (Profile Executor + Benchmarks)
**Complexity:** L

## Objective

Deliver the data types that represent ranking profiles as runtime data, not compiled code. A `RankingProfile` is a named, versioned scoring function declared in schema and stored in a `ProfileRegistry`. It specifies how candidates are scored via `ScoringRule` variants (boosts, gates, penalties, excludes), how they are retrieved via `CandidateStrategy`, and how they are ordered via an optional `Sort` override. The entire profile is serializable, validatable, and swappable at query time without recompilation.

This task builds the type foundation for the entire ranking subsystem. Every subsequent task in m2p3 (built-in profiles, executor, benchmarks) depends on these types. The design must match Spec 09 Sections 2-8 and 11 exactly, while remaining pragmatic about what M2 actually executes vs what is declared as a type stub for future milestones.

## Requirements

- `RankingProfile` struct with all fields from Spec 09 Section 2.1 (`ProfileDef`)
- `ScoringRule` enum covering Boost, Gate, Penalty, Exclude from Spec 09 Sections 5-7
- `Sort` enum with all formula variants from Spec 09 Section 11
- `CandidateStrategy` enum with all variants from Spec 09 Section 3
- `SignalAgg` enum for signal aggregation modes from Spec 09 Section 5.1
- `ProfileRegistry` with name-to-versioned-profile mapping, registration, lookup
- Validation at registration time: duplicate version rejection, signal reference checking, gate threshold range, inheritance cycle detection
- Serde `Serialize`/`Deserialize` on all profile types for checkpoint persistence
- No `unsafe` code

## Technical Design

### Module Structure

```
tidal/src/ranking/
  profile.rs    -- RankingProfile, ScoringRule, Sort, CandidateStrategy, SignalAgg,
                   Boost, Gate, Penalty, Exclude, ProfileDecay, DiversitySpec, validation
  registry.rs   -- ProfileRegistry, ProfileError
  mod.rs        -- pub use re-exports
```

### Public API

```rust
// === ranking/profile.rs ===

use serde::{Deserialize, Serialize};
use crate::schema::{EntityKind, Window};

/// A named, versioned ranking profile declared as runtime data.
///
/// Profiles are schema-level declarations stored in the database.
/// A profile change never requires recompilation or redeployment.
/// The query executor resolves a profile by name, loads it from the
/// registry, and executes the scoring pipeline it defines.
///
/// # Spec Reference
///
/// Spec 09 Section 2.1 (ProfileDef structure).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RankingProfile {
    /// Unique profile name. Lowercase alphanumeric plus underscores.
    name: String,

    /// Monotonically increasing version number.
    version: u32,

    /// How candidates are generated (Spec 09 Section 3).
    /// Informational for the RETRIEVE executor -- the profile executor
    /// itself receives pre-generated candidates.
    candidate_strategy: CandidateStrategy,

    /// Positive signal boosts added to candidate scores (Spec 09 Section 5).
    boosts: Vec<Boost>,

    /// Content age decay applied multiplicatively to all scores (Spec 09 Section 5.4).
    decay: Option<ProfileDecay>,

    /// Quality gates -- hard thresholds that exclude candidates (Spec 09 Section 7).
    gates: Vec<Gate>,

    /// Negative signal penalties subtracted from scores (Spec 09 Section 6).
    penalties: Vec<Penalty>,

    /// Hard exclusions -- items matching these are removed before scoring.
    excludes: Vec<Exclude>,

    /// Post-scoring diversity constraints (Spec 09 Section 9).
    /// Stored on the profile but executed by the diversity engine (m2p4).
    diversity: Option<DiversitySpec>,

    /// Fraction of results reserved for exploration (Spec 09 Section 10).
    /// Range: 0.0 to 0.5. Default: 0.0 (no exploration).
    exploration: f64,

    /// Optional sort mode override. When set, bypasses the boost/penalty
    /// scoring pipeline and uses a formula-based sort (Spec 09 Section 11).
    sort: Option<Sort>,

    /// Whether this is a built-in profile (registered by the engine,
    /// not the application). Built-in profiles can be overridden by
    /// application profiles with the same name.
    is_builtin: bool,
}

impl RankingProfile {
    /// Construct a new ranking profile.
    ///
    /// Validation is performed at `ProfileRegistry::register()` time,
    /// not at construction. This allows building profiles incrementally.
    pub fn new(name: impl Into<String>, version: u32) -> Self {
        Self {
            name: name.into(),
            version,
            candidate_strategy: CandidateStrategy::Scan {
                entity: EntityKind::Item,
            },
            boosts: Vec::new(),
            decay: None,
            gates: Vec::new(),
            penalties: Vec::new(),
            excludes: Vec::new(),
            diversity: None,
            exploration: 0.0,
            sort: None,
            is_builtin: false,
        }
    }

    // Builder methods (all return &mut Self for chaining):
    pub fn with_candidate_strategy(&mut self, strategy: CandidateStrategy) -> &mut Self;
    pub fn with_boost(&mut self, boost: Boost) -> &mut Self;
    pub fn with_boosts(&mut self, boosts: Vec<Boost>) -> &mut Self;
    pub fn with_decay(&mut self, decay: ProfileDecay) -> &mut Self;
    pub fn with_gate(&mut self, gate: Gate) -> &mut Self;
    pub fn with_gates(&mut self, gates: Vec<Gate>) -> &mut Self;
    pub fn with_penalty(&mut self, penalty: Penalty) -> &mut Self;
    pub fn with_penalties(&mut self, penalties: Vec<Penalty>) -> &mut Self;
    pub fn with_exclude(&mut self, exclude: Exclude) -> &mut Self;
    pub fn with_excludes(&mut self, excludes: Vec<Exclude>) -> &mut Self;
    pub fn with_diversity(&mut self, diversity: DiversitySpec) -> &mut Self;
    pub fn with_exploration(&mut self, budget: f64) -> &mut Self;
    pub fn with_sort(&mut self, sort: Sort) -> &mut Self;
    pub fn set_builtin(&mut self, builtin: bool) -> &mut Self;

    // Getters:
    pub fn name(&self) -> &str;
    pub fn version(&self) -> u32;
    pub fn candidate_strategy(&self) -> &CandidateStrategy;
    pub fn boosts(&self) -> &[Boost];
    pub fn decay(&self) -> Option<&ProfileDecay>;
    pub fn gates(&self) -> &[Gate];
    pub fn penalties(&self) -> &[Penalty];
    pub fn excludes(&self) -> &[Exclude];
    pub fn diversity(&self) -> Option<&DiversitySpec>;
    pub fn exploration(&self) -> f64;
    pub fn sort(&self) -> Option<&Sort>;
    pub fn is_builtin(&self) -> bool;

    /// Returns true if this profile uses a sort mode override
    /// (bypasses boost/penalty pipeline).
    pub fn has_sort_override(&self) -> bool;

    /// Returns all signal names referenced by this profile's boosts,
    /// penalties, and gates. Used for dependency validation and
    /// signal snapshot construction.
    pub fn referenced_signals(&self) -> Vec<&str>;
}

/// How candidates are generated. Spec 09 Section 3.
///
/// The profile executor does not execute candidate strategies --
/// the RETRIEVE executor (m2p5) does. These are informational:
/// they tell the RETRIEVE executor which retrieval path to use.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum CandidateStrategy {
    /// Vector similarity search over embeddings. Spec 09 Section 3.1.
    Ann {
        query_source: VectorSource,
        entity: EntityKind,
        top_k: u32,
    },
    /// Full entity scan with signal-based ranking. Spec 09 Section 3.2.
    Scan {
        entity: EntityKind,
    },
    /// Text + vector hybrid fusion. Spec 09 Section 3.3.
    Hybrid {
        text_weight: f64,
        vector_weight: f64,
        fusion: FusionStrategy,
    },
    /// Graph traversal via relationship edges. Spec 09 Section 3.4.
    Relationship {
        edge: String,
    },
    /// Cohort-scoped trending. Spec 09 Section 3.5.
    CohortTrending {
        window: Window,
        top_k: u32,
    },
}

/// Source of the query vector for ANN candidate generation.
/// Spec 09 Section 3.1 VectorSource variants.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum VectorSource {
    /// The querying user's preference vector.
    UserPreference,
    /// A specific item's embedding.
    ItemEmbedding(String),
    /// The query vector passed inline (for SEARCH).
    QueryEmbedding,
    /// A creator's catalog embedding.
    CreatorEmbedding(String),
}

/// Fusion strategy for hybrid text + vector search.
/// Spec 09 Section 3.3.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FusionStrategy {
    /// Reciprocal Rank Fusion with configurable k.
    Rrf { k: u32 },
    /// Weighted linear combination (requires relevance labels).
    Linear { alpha: f64 },
}

/// A positive signal boost. Spec 09 Section 5.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Boost {
    /// The signal name (e.g., "view", "like", "share").
    pub signal: String,
    /// Time window for aggregation.
    pub window: Window,
    /// How to aggregate the signal within the window.
    pub aggregation: SignalAgg,
    /// Contribution weight. Typically 0.0 to 1.0.
    pub weight: f64,
}

impl Boost {
    pub fn new(signal: impl Into<String>, window: Window, aggregation: SignalAgg, weight: f64) -> Self {
        Self {
            signal: signal.into(),
            window,
            aggregation,
            weight,
        }
    }
}

/// How a signal value is aggregated within a window.
/// Spec 09 Section 5.1 SignalAgg variants.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SignalAgg {
    /// Raw aggregate value (count or weighted sum) in the window.
    Value,
    /// Rate of change within the window (events per hour).
    Velocity,
    /// Signal value divided by view count (engagement ratio).
    Ratio,
    /// Running exponential decay score from hot tier.
    DecayScore,
    /// Short-window velocity / long-window velocity.
    RelativeVelocity,
}

/// Content age decay applied multiplicatively to scores.
/// Spec 09 Section 5.4.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProfileDecay {
    /// Metadata field name containing the timestamp (typically "created_at").
    pub field: String,
    /// Half-life for exponential age decay.
    pub half_life_secs: f64,
}

impl ProfileDecay {
    pub fn new(field: impl Into<String>, half_life: std::time::Duration) -> Self {
        Self {
            field: field.into(),
            half_life_secs: half_life.as_secs_f64(),
        }
    }
}

/// A quality gate -- hard threshold that excludes candidates.
/// Spec 09 Section 7.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Gate {
    /// Items where signal value in window < threshold are excluded.
    /// Spec 09 Section 7.1.
    MinSignal {
        signal: String,
        window: Window,
        threshold: f64,
    },
    /// Items where a named ratio < threshold are excluded.
    /// Spec 09 Section 7.2.
    MinRatio {
        ratio_name: String,
        threshold: f64,
    },
    /// Items with fewer than count events are excluded.
    /// Spec 09 Section 7.3.
    MinCount {
        signal: String,
        window: Window,
        count: u64,
    },
}

impl Gate {
    pub fn min_signal(signal: impl Into<String>, window: Window, threshold: f64) -> Self {
        Self::MinSignal {
            signal: signal.into(),
            window,
            threshold,
        }
    }

    pub fn min_ratio(ratio_name: impl Into<String>, threshold: f64) -> Self {
        Self::MinRatio {
            ratio_name: ratio_name.into(),
            threshold,
        }
    }

    pub fn min_count(signal: impl Into<String>, window: Window, count: u64) -> Self {
        Self::MinCount {
            signal: signal.into(),
            window,
            count,
        }
    }

    /// Returns the signal name referenced by this gate, if any.
    pub fn signal_name(&self) -> Option<&str> {
        match self {
            Self::MinSignal { signal, .. } | Self::MinCount { signal, .. } => Some(signal),
            Self::MinRatio { .. } => None,
        }
    }

    /// Returns the threshold value for display/debugging.
    pub fn threshold_display(&self) -> String;
}

/// A negative signal penalty. Spec 09 Section 6.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Penalty {
    /// The signal name (e.g., "skip", "dislike", "downvote").
    pub signal: String,
    /// Time window for aggregation.
    pub window: Window,
    /// Penalty weight (stored as positive; applied as negative during scoring).
    pub weight: f64,
}

impl Penalty {
    pub fn new(signal: impl Into<String>, window: Window, weight: f64) -> Self {
        Self {
            signal: signal.into(),
            window,
            weight,
        }
    }
}

/// Hard exclusion -- items matching these are removed before scoring.
/// Spec 09 Section 4, Stage 2.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Exclude {
    /// Exclude items where the user has the named signal (e.g., "hide").
    Signal(String),
    /// Exclude items by creators with this relationship (e.g., "blocked").
    Relationship(String),
}

/// Post-scoring diversity constraints. Spec 09 Section 9.
/// Stored on the profile but executed by the diversity engine (m2p4).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DiversitySpec {
    /// Maximum items from the same creator in the result set.
    pub max_per_creator: Option<u32>,
    /// Ensure variety of content formats.
    pub format_mix: bool,
    /// Topic diversity score [0.0, 1.0]. MMR lambda.
    pub topic_diversity: Option<f64>,
    /// Minimum items per represented category.
    pub category_min: Option<u32>,
}

/// Built-in sort mode formulas. Spec 09 Section 11.
///
/// When a profile has a sort override, the sort formula replaces
/// the boost/penalty scoring stages (stages 4-5). Gates, exclusions,
/// normalization, and diversity still apply.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Sort {
    /// `log10(max(|positive - negative|, 1)) / (age_hours + 2)^gravity`
    /// Spec 09 Section 11.1. Default gravity: 1.8.
    Hot { gravity: f64 },

    /// `share_velocity(6h) * 0.5 + view_velocity(6h) * 0.3 + new_user_reach(24h) * 0.2`
    /// Spec 09 Section 11.2.
    Trending,

    /// `relative_velocity * age_boost`. Spec 09 Section 11.3.
    Rising,

    /// `(positive * negative) / (positive + negative)^2`
    /// Spec 09 Section 11.4.
    Controversial,

    /// `quality_score * (1 / log10(view_count + 10))`
    /// Spec 09 Section 11.5.
    HiddenGems,

    /// `random(seed) * quality_weight`. Deterministic per session.
    /// Spec 09 Section 11.6.
    Shuffle,

    /// `created_at DESC`. Pure chronological, no scoring.
    /// Spec 09 Section 11.8.
    New,

    /// Windowed quality score sum. Spec 09 Section 11.7.
    TopWindow { window: Window },

    /// `view.count(window) DESC`. Raw popularity.
    MostViewed { window: Window },

    /// `like.count(window) DESC`. Positive sentiment.
    MostLiked { window: Window },
}
```

```rust
// === ranking/registry.rs ===

use std::collections::HashMap;
use super::profile::RankingProfile;

/// Error type for profile registry operations.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProfileError {
    /// Profile name contains invalid characters (not lowercase alphanumeric + underscore).
    InvalidName(String),
    /// Version is not monotonically increasing (INV-PROF-1).
    VersionConflict {
        name: String,
        existing_version: u32,
        attempted_version: u32,
    },
    /// Exploration budget out of range [0.0, 0.5].
    ExplorationOutOfRange(f64),
    /// Gate threshold out of range [0.0, 1.0].
    GateThresholdOutOfRange {
        gate_description: String,
        threshold: f64,
    },
    /// Profile not found by name.
    NotFound(String),
    /// Profile not found by name + version.
    VersionNotFound { name: String, version: u32 },
}

impl std::fmt::Display for ProfileError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result;
}

impl std::error::Error for ProfileError {}

/// Registry mapping profile names to versioned `RankingProfile` instances.
///
/// Profiles are loaded into memory at startup and cached for the
/// lifetime of the database instance. The registry enforces version
/// monotonicity (INV-PROF-1) and validates profile structure at
/// registration time.
///
/// # Spec Reference
///
/// Spec 09 Section 2.2 (version semantics), Section 16 (INV-PROF-1, INV-PROF-2, INV-PROF-3).
pub struct ProfileRegistry {
    /// Map from profile name to list of versions (sorted ascending by version number).
    profiles: HashMap<String, Vec<RankingProfile>>,
}

impl ProfileRegistry {
    /// Create an empty registry.
    pub fn new() -> Self;

    /// Register a profile. Validates structure and enforces version monotonicity.
    ///
    /// If a profile with this name already exists, the new version must be
    /// strictly greater than the existing latest version (INV-PROF-1).
    ///
    /// Returns `ProfileError::VersionConflict` if the version is not
    /// monotonically increasing.
    pub fn register(&mut self, profile: RankingProfile) -> Result<(), ProfileError>;

    /// Get the latest version of a profile by name.
    pub fn get(&self, name: &str) -> Result<&RankingProfile, ProfileError>;

    /// Get a specific version of a profile.
    pub fn get_versioned(&self, name: &str, version: u32) -> Result<&RankingProfile, ProfileError>;

    /// List all profile names.
    pub fn list_names(&self) -> Vec<&str>;

    /// List all profiles (latest version of each).
    pub fn list_latest(&self) -> Vec<&RankingProfile>;

    /// Check if a profile exists by name.
    pub fn contains(&self, name: &str) -> bool;

    /// Number of distinct profile names.
    pub fn len(&self) -> usize;

    /// Whether the registry is empty.
    pub fn is_empty(&self) -> bool;

    /// Remove a profile by name (all versions). Returns true if the profile existed.
    /// Used when an application overrides a built-in profile.
    pub fn remove(&mut self, name: &str) -> bool;
}

impl Default for ProfileRegistry {
    fn default() -> Self {
        Self::new()
    }
}
```

```rust
// === ranking/mod.rs ===

pub mod profile;
pub mod registry;

pub use profile::{
    Boost, CandidateStrategy, DiversitySpec, Exclude, FusionStrategy, Gate,
    Penalty, ProfileDecay, RankingProfile, SignalAgg, Sort, VectorSource,
};
pub use registry::{ProfileError, ProfileRegistry};
```

### Validation Rules

Validation is performed in `ProfileRegistry::register()`:

1. **Name format:** Lowercase alphanumeric plus underscores. Regex: `^[a-z][a-z0-9_]*$`. Minimum 1 character, maximum 64 characters.

2. **Version monotonicity (INV-PROF-1):** If a profile with this name already exists, `new_version > existing_latest_version`. Returns `ProfileError::VersionConflict` on violation.

3. **Exploration range:** `exploration` must be in `[0.0, 0.5]`. Returns `ProfileError::ExplorationOutOfRange` if outside.

4. **Gate threshold range:** For `Gate::MinSignal` and `Gate::MinRatio`, `threshold` must be in `[0.0, 1.0]`. For `Gate::MinCount`, `count` must be > 0. Returns `ProfileError::GateThresholdOutOfRange` if outside.

5. **Weight sign:** Boost weights must be non-negative. Penalty weights must be non-negative (they are applied as negative during scoring). A `tracing::warn!` is emitted if any weight > 5.0 (likely a misconfiguration, but not rejected).

6. **Signal reference validation:** Deferred to Task 02 where signal dependencies are checked against the schema. Task 01 validation does not require a schema reference.

### Serde Strategy

All profile types derive `Serialize` and `Deserialize`. The format is JSON for human readability during development. The schema checkpoint serializes profiles alongside signal definitions and entity definitions.

`Window` already has `Serialize`/`Deserialize` from m1p1 (or needs it added). If not present, add `#[derive(Serialize, Deserialize)]` to `Window` in `schema/signal.rs`. Similarly for `EntityKind`.

### Error Handling

- `ProfileError` is a non-exhaustive enum with `Display` and `Error` impls.
- `ProfileError` integrates with `LumenError` via a `From<ProfileError>` impl that maps to `LumenError::Schema(SchemaError::ProfileError(...))` or a new `LumenError::Profile(ProfileError)` variant -- choose whichever maintains the existing error hierarchy.
- Validation errors are returned from `register()`, not from `RankingProfile::new()`. Construction is infallible to support builder patterns.

## Test Strategy

### Unit Tests

```rust
#[test]
fn profile_construction_defaults() {
    let profile = RankingProfile::new("test", 1);
    assert_eq!(profile.name(), "test");
    assert_eq!(profile.version(), 1);
    assert!(profile.boosts().is_empty());
    assert!(profile.gates().is_empty());
    assert!(profile.penalties().is_empty());
    assert!(profile.excludes().is_empty());
    assert!(profile.decay().is_none());
    assert!(profile.diversity().is_none());
    assert!((profile.exploration() - 0.0).abs() < f64::EPSILON);
    assert!(profile.sort().is_none());
    assert!(!profile.is_builtin());
    assert!(!profile.has_sort_override());
}

#[test]
fn profile_builder_chaining() {
    let mut profile = RankingProfile::new("trending", 1);
    profile
        .with_candidate_strategy(CandidateStrategy::Scan {
            entity: EntityKind::Item,
        })
        .with_boost(Boost::new("share", Window::OneHour, SignalAgg::Velocity, 0.5))
        .with_boost(Boost::new("view", Window::OneHour, SignalAgg::Velocity, 0.3))
        .with_gate(Gate::min_ratio("engagement_ratio", 0.03))
        .with_diversity(DiversitySpec {
            max_per_creator: Some(1),
            ..Default::default()
        })
        .with_sort(Sort::Trending);

    assert_eq!(profile.boosts().len(), 2);
    assert_eq!(profile.gates().len(), 1);
    assert!(profile.has_sort_override());
}

#[test]
fn profile_referenced_signals() {
    let mut profile = RankingProfile::new("test", 1);
    profile
        .with_boost(Boost::new("view", Window::TwentyFourHours, SignalAgg::Velocity, 0.3))
        .with_boost(Boost::new("share", Window::OneHour, SignalAgg::Velocity, 0.5))
        .with_penalty(Penalty::new("skip", Window::TwentyFourHours, 0.5))
        .with_gate(Gate::min_signal("completion", Window::AllTime, 0.3));

    let refs = profile.referenced_signals();
    assert!(refs.contains(&"view"));
    assert!(refs.contains(&"share"));
    assert!(refs.contains(&"skip"));
    assert!(refs.contains(&"completion"));
}

#[test]
fn registry_register_and_get() {
    let mut registry = ProfileRegistry::new();
    let profile = RankingProfile::new("trending", 1);
    registry.register(profile).unwrap();

    let retrieved = registry.get("trending").unwrap();
    assert_eq!(retrieved.name(), "trending");
    assert_eq!(retrieved.version(), 1);
}

#[test]
fn registry_version_monotonicity() {
    let mut registry = ProfileRegistry::new();
    registry.register(RankingProfile::new("test", 1)).unwrap();
    registry.register(RankingProfile::new("test", 2)).unwrap();

    // Version 1 again -- should fail
    let err = registry.register(RankingProfile::new("test", 1)).unwrap_err();
    assert!(matches!(err, ProfileError::VersionConflict { .. }));

    // Same version as latest -- should fail
    let err = registry.register(RankingProfile::new("test", 2)).unwrap_err();
    assert!(matches!(err, ProfileError::VersionConflict { .. }));

    // Higher version -- should succeed
    registry.register(RankingProfile::new("test", 3)).unwrap();
}

#[test]
fn registry_get_latest_returns_highest_version() {
    let mut registry = ProfileRegistry::new();
    registry.register(RankingProfile::new("test", 1)).unwrap();
    registry.register(RankingProfile::new("test", 2)).unwrap();
    registry.register(RankingProfile::new("test", 5)).unwrap();

    let latest = registry.get("test").unwrap();
    assert_eq!(latest.version(), 5);
}

#[test]
fn registry_get_versioned() {
    let mut registry = ProfileRegistry::new();
    registry.register(RankingProfile::new("test", 1)).unwrap();
    registry.register(RankingProfile::new("test", 3)).unwrap();

    let v1 = registry.get_versioned("test", 1).unwrap();
    assert_eq!(v1.version(), 1);

    let v3 = registry.get_versioned("test", 3).unwrap();
    assert_eq!(v3.version(), 3);

    let err = registry.get_versioned("test", 2).unwrap_err();
    assert!(matches!(err, ProfileError::VersionNotFound { .. }));
}

#[test]
fn registry_not_found() {
    let registry = ProfileRegistry::new();
    let err = registry.get("nonexistent").unwrap_err();
    assert!(matches!(err, ProfileError::NotFound(_)));
}

#[test]
fn registry_list_names() {
    let mut registry = ProfileRegistry::new();
    registry.register(RankingProfile::new("alpha", 1)).unwrap();
    registry.register(RankingProfile::new("beta", 1)).unwrap();

    let names = registry.list_names();
    assert_eq!(names.len(), 2);
    assert!(names.contains(&"alpha"));
    assert!(names.contains(&"beta"));
}

#[test]
fn registry_contains() {
    let mut registry = ProfileRegistry::new();
    registry.register(RankingProfile::new("test", 1)).unwrap();
    assert!(registry.contains("test"));
    assert!(!registry.contains("other"));
}

#[test]
fn registry_remove() {
    let mut registry = ProfileRegistry::new();
    registry.register(RankingProfile::new("test", 1)).unwrap();
    assert!(registry.remove("test"));
    assert!(!registry.contains("test"));
    assert!(!registry.remove("test")); // already removed
}

#[test]
fn validation_invalid_name() {
    let mut registry = ProfileRegistry::new();

    // Uppercase
    let err = registry.register(RankingProfile::new("Test", 1)).unwrap_err();
    assert!(matches!(err, ProfileError::InvalidName(_)));

    // Starts with number
    let err = registry.register(RankingProfile::new("1test", 1)).unwrap_err();
    assert!(matches!(err, ProfileError::InvalidName(_)));

    // Contains hyphen
    let err = registry.register(RankingProfile::new("my-profile", 1)).unwrap_err();
    assert!(matches!(err, ProfileError::InvalidName(_)));

    // Empty
    let err = registry.register(RankingProfile::new("", 1)).unwrap_err();
    assert!(matches!(err, ProfileError::InvalidName(_)));
}

#[test]
fn validation_exploration_range() {
    let mut registry = ProfileRegistry::new();

    let mut profile = RankingProfile::new("test", 1);
    profile.with_exploration(0.6); // > 0.5
    let err = registry.register(profile).unwrap_err();
    assert!(matches!(err, ProfileError::ExplorationOutOfRange(_)));

    let mut profile = RankingProfile::new("test", 1);
    profile.with_exploration(-0.1); // < 0.0
    let err = registry.register(profile).unwrap_err();
    assert!(matches!(err, ProfileError::ExplorationOutOfRange(_)));

    // Edge cases: 0.0 and 0.5 are valid
    let mut profile = RankingProfile::new("test_zero", 1);
    profile.with_exploration(0.0);
    registry.register(profile).unwrap();

    let mut profile = RankingProfile::new("test_half", 1);
    profile.with_exploration(0.5);
    registry.register(profile).unwrap();
}

#[test]
fn validation_gate_threshold_range() {
    let mut registry = ProfileRegistry::new();

    let mut profile = RankingProfile::new("test", 1);
    profile.with_gate(Gate::min_signal("view", Window::AllTime, 1.5)); // > 1.0
    let err = registry.register(profile).unwrap_err();
    assert!(matches!(err, ProfileError::GateThresholdOutOfRange { .. }));

    let mut profile = RankingProfile::new("test", 1);
    profile.with_gate(Gate::min_ratio("engagement_ratio", -0.1)); // < 0.0
    let err = registry.register(profile).unwrap_err();
    assert!(matches!(err, ProfileError::GateThresholdOutOfRange { .. }));
}

#[test]
fn serde_roundtrip() {
    let mut profile = RankingProfile::new("trending", 1);
    profile
        .with_candidate_strategy(CandidateStrategy::Scan {
            entity: EntityKind::Item,
        })
        .with_boost(Boost::new("share", Window::OneHour, SignalAgg::Velocity, 0.5))
        .with_gate(Gate::min_ratio("engagement_ratio", 0.03))
        .with_penalty(Penalty::new("skip", Window::TwentyFourHours, 0.5))
        .with_diversity(DiversitySpec {
            max_per_creator: Some(1),
            format_mix: true,
            ..Default::default()
        })
        .with_sort(Sort::Trending);

    let json = serde_json::to_string(&profile).unwrap();
    let restored: RankingProfile = serde_json::from_str(&json).unwrap();

    assert_eq!(restored.name(), profile.name());
    assert_eq!(restored.version(), profile.version());
    assert_eq!(restored.boosts().len(), profile.boosts().len());
    assert_eq!(restored.gates().len(), profile.gates().len());
    assert_eq!(restored.penalties().len(), profile.penalties().len());
    assert!(restored.has_sort_override());
}

#[test]
fn sort_variants_serde_roundtrip() {
    let sorts = vec![
        Sort::Hot { gravity: 1.8 },
        Sort::Trending,
        Sort::Rising,
        Sort::Controversial,
        Sort::HiddenGems,
        Sort::Shuffle,
        Sort::New,
        Sort::TopWindow { window: Window::SevenDays },
        Sort::MostViewed { window: Window::AllTime },
        Sort::MostLiked { window: Window::AllTime },
    ];

    for sort in &sorts {
        let json = serde_json::to_string(sort).unwrap();
        let restored: Sort = serde_json::from_str(&json).unwrap();
        // Verify roundtrip (Debug format comparison since Sort doesn't derive PartialEq)
        assert_eq!(format!("{sort:?}"), format!("{restored:?}"));
    }
}

#[test]
fn candidate_strategy_variants_serde_roundtrip() {
    let strategies = vec![
        CandidateStrategy::Scan { entity: EntityKind::Item },
        CandidateStrategy::Ann {
            query_source: VectorSource::UserPreference,
            entity: EntityKind::Item,
            top_k: 500,
        },
        CandidateStrategy::Hybrid {
            text_weight: 0.6,
            vector_weight: 0.4,
            fusion: FusionStrategy::Rrf { k: 60 },
        },
        CandidateStrategy::Relationship { edge: "follows".into() },
        CandidateStrategy::CohortTrending {
            window: Window::TwentyFourHours,
            top_k: 200,
        },
    ];

    for strategy in &strategies {
        let json = serde_json::to_string(strategy).unwrap();
        let _restored: CandidateStrategy = serde_json::from_str(&json).unwrap();
    }
}

#[test]
fn diversity_spec_default() {
    let spec = DiversitySpec::default();
    assert!(spec.max_per_creator.is_none());
    assert!(!spec.format_mix);
    assert!(spec.topic_diversity.is_none());
    assert!(spec.category_min.is_none());
}

#[test]
fn gate_signal_name() {
    let gate = Gate::min_signal("completion", Window::AllTime, 0.3);
    assert_eq!(gate.signal_name(), Some("completion"));

    let gate = Gate::min_count("view", Window::AllTime, 100);
    assert_eq!(gate.signal_name(), Some("view"));

    let gate = Gate::min_ratio("engagement_ratio", 0.03);
    assert_eq!(gate.signal_name(), None);
}
```

### Property Tests

```rust
use proptest::prelude::*;

// P1: Version monotonicity is enforced.
proptest! {
    #[test]
    fn version_monotonicity_enforced(
        versions in prop::collection::vec(1u32..1000, 2..10),
    ) {
        let mut registry = ProfileRegistry::new();
        let mut max_registered = 0u32;

        for v in versions {
            let result = registry.register(RankingProfile::new("test", v));
            if v > max_registered {
                prop_assert!(result.is_ok(),
                    "version {} should be accepted (max was {})", v, max_registered);
                max_registered = v;
            } else {
                prop_assert!(result.is_err(),
                    "version {} should be rejected (max is {})", v, max_registered);
            }
        }
    }
}

// P2: Serde roundtrip preserves all fields.
proptest! {
    #[test]
    fn serde_roundtrip_preserves_name_and_version(
        name in "[a-z][a-z0-9_]{0,15}",
        version in 1u32..1000,
    ) {
        let profile = RankingProfile::new(&name, version);
        let json = serde_json::to_string(&profile).unwrap();
        let restored: RankingProfile = serde_json::from_str(&json).unwrap();
        prop_assert_eq!(restored.name(), name.as_str());
        prop_assert_eq!(restored.version(), version);
    }
}

// P3: Valid names are accepted, invalid names are rejected.
proptest! {
    #[test]
    fn valid_names_accepted(name in "[a-z][a-z0-9_]{0,15}") {
        let mut registry = ProfileRegistry::new();
        let result = registry.register(RankingProfile::new(&name, 1));
        prop_assert!(result.is_ok(), "name '{}' should be valid", name);
    }
}
```

## Acceptance Criteria

- [ ] `RankingProfile` struct with all fields: name, version, candidate_strategy, boosts, decay, gates, penalties, excludes, diversity, exploration, sort, is_builtin
- [ ] Builder methods on `RankingProfile` for fluent construction
- [ ] `referenced_signals()` returns all signal names used in boosts, penalties, and gates
- [ ] `has_sort_override()` returns true when `sort` is `Some`
- [ ] `CandidateStrategy` enum with 5 variants: Ann, Scan, Hybrid, Relationship, CohortTrending
- [ ] `VectorSource` enum with 4 variants: UserPreference, ItemEmbedding, QueryEmbedding, CreatorEmbedding
- [ ] `FusionStrategy` enum with 2 variants: Rrf, Linear
- [ ] `Boost` struct with signal, window, aggregation, weight
- [ ] `SignalAgg` enum with 5 variants: Value, Velocity, Ratio, DecayScore, RelativeVelocity
- [ ] `Gate` enum with 3 variants: MinSignal, MinRatio, MinCount
- [ ] `Penalty` struct with signal, window, weight
- [ ] `Exclude` enum with 2 variants: Signal, Relationship
- [ ] `DiversitySpec` struct with max_per_creator, format_mix, topic_diversity, category_min
- [ ] `ProfileDecay` struct with field, half_life_secs
- [ ] `Sort` enum with 10 variants: Hot, Trending, Rising, Controversial, HiddenGems, Shuffle, New, TopWindow, MostViewed, MostLiked
- [ ] `ProfileRegistry` with register, get, get_versioned, list_names, list_latest, contains, remove
- [ ] `ProfileError` enum with InvalidName, VersionConflict, ExplorationOutOfRange, GateThresholdOutOfRange, NotFound, VersionNotFound
- [ ] Version monotonicity enforced (INV-PROF-1): `register()` rejects version <= existing latest
- [ ] Name validation: lowercase alphanumeric + underscore, starts with letter, 1-64 chars
- [ ] Exploration range validation: [0.0, 0.5]
- [ ] Gate threshold range validation: [0.0, 1.0] for MinSignal and MinRatio
- [ ] All types derive `Serialize` + `Deserialize` (serde)
- [ ] Serde roundtrip (JSON) preserves all fields for all type variants
- [ ] `Serialize`/`Deserialize` added to `Window` and `EntityKind` in m1p1 types if not already present
- [ ] No `unsafe` code
- [ ] `cargo clippy -- -D warnings` passes
- [ ] All unit tests and property tests pass

## Research References

- [docs/research/tidaldb_signal_ledger.md](../../../research/tidaldb_signal_ledger.md) -- Signal type definitions that profiles reference (decay rates, windows, velocity)

## Spec References

- [docs/specs/09-ranking-scoring.md](../../../specs/09-ranking-scoring.md) -- Section 2 (ProfileDef structure, versioning rules, inheritance), Section 3 (CandidateStrategy variants), Section 5 (Boost types, SignalAgg), Section 6 (Penalty types), Section 7 (Quality gates: MinSignal, MinRatio, MinCount), Section 8 (Score composition), Section 9 (DiversitySpec), Section 10 (Exploration budget), Section 11 (Sort mode formulas), Section 16 (INV-PROF-1 version monotonicity, INV-PROF-2 inheritance acyclicity, INV-PROF-3 signal reference validity)

## Implementation Notes

- Add `serde = { version = "1", features = ["derive"] }` and `serde_json = "1"` to `Cargo.toml` dependencies if not already present. `serde_json` is needed for checkpoint serialization and tests.
- `Window` and `EntityKind` in `schema/signal.rs` and `schema/entity.rs` need `#[derive(Serialize, Deserialize)]`. If this creates a dependency conflict (m1p1 does not depend on serde), add serde behind a feature flag `serde` or add it as a direct dependency. Given that profiles are a core data type that must persist, serde is a reasonable direct dependency.
- Profile inheritance (`extends` field from Spec 09 Section 2.3) is deferred to M5+. For M2, profiles are standalone -- no parent resolution. The `extends` field is not included in the `RankingProfile` struct. Add it when inheritance is needed.
- The `is_builtin` flag allows the registry to distinguish engine-registered profiles from application-registered profiles. When an application registers a profile with the same name as a built-in, the built-in is replaced (Spec 09 Section 13.13).
- Do NOT implement profile persistence to the storage engine in this task. Profiles are held in memory in the `ProfileRegistry`. Checkpoint persistence (serialize profiles to storage via `Tag::Meta`) is wired up in a later phase when the schema checkpoint is extended.
- Do NOT implement signal reference validation against the schema in this task. That validation requires access to the `Schema`'s signal definitions and is implemented in Task 02 where built-in profiles are registered with schema context.
