# Task 01: Diversity Types + Greedy Selector

## Context

**Milestone:** 2 -- Ranked Retrieval
**Phase:** m2p4 -- Diversity Enforcement
**Depends On:** None (uses types from m2p3 but no m2p4 tasks)
**Blocks:** Task 02 (Property Tests + Benchmarks)
**Complexity:** M

## Objective

Deliver the diversity enforcement engine: types (`DiversityConstraints`, `DiversityResult`, `ConstraintViolation`) and the `DiversitySelector` that takes a scored, sorted candidate list and selects results that satisfy diversity constraints without reducing result count. The selector uses a single greedy pass over candidates in score order, maintaining per-creator and per-format counts, skipping candidates that would violate active constraints, and relaxing constraints in a defined three-stage order when the target count cannot be met.

This is the component that prevents a single prolific creator from dominating a feed, and prevents a single content format from crowding out others. It is a post-scoring reranking step -- it does not alter scores, only the selection of which scored candidates make it into the final result set. The key invariant: diversity never reduces result count (Spec 09 Section 9.5, INV-RANK-5).

## Requirements

- `DiversityConstraints` struct: runtime configuration for the diversity pass
- `DiversityResult` struct: selected candidates plus constraint satisfaction status
- `ConstraintViolation` enum: describes which constraints could not be fully satisfied
- `DiversitySelector::select()`: greedy selection with three-stage relaxation
- `ScoredCandidate` extended with `creator_id` and `format` fields
- `From<DiversitySpec>` impl for constructing `DiversityConstraints` from profile config
- No `unsafe` code

## Technical Design

### Module Structure

```
tidal/src/ranking/
  diversity.rs   -- DiversityConstraints, DiversityResult, DiversitySelector,
                    ConstraintViolation, From<DiversitySpec>
  mod.rs         -- add pub mod diversity; and re-exports
```

### ScoredCandidate Extension

`ScoredCandidate` (defined in m2p3 Task 03, `ranking/executor.rs`) gains two optional metadata fields needed by the diversity selector:

```rust
// === ranking/executor.rs (modification) ===

/// A scored candidate with signal transparency data.
#[derive(Debug, Clone)]
pub struct ScoredCandidate {
    /// The entity that was scored.
    pub entity_id: EntityId,

    /// The composite score after boost/penalty/gate/normalization.
    pub score: f64,

    /// Key signal values used in scoring. For debugging/transparency.
    pub signal_snapshot: Vec<(String, f64)>,

    /// Creator ID for diversity enforcement. Populated by the executor
    /// from entity metadata when the profile has diversity constraints.
    /// None if creator metadata is not available or diversity is not needed.
    pub creator_id: Option<EntityId>,

    /// Content format for diversity enforcement (e.g., "video", "article",
    /// "short", "podcast"). Populated from entity metadata.
    /// None if format metadata is not available.
    pub format: Option<String>,
}

impl ScoredCandidate {
    /// Construct a scored candidate (used internally and in tests).
    pub fn new(entity_id: EntityId, score: f64) -> Self {
        Self {
            entity_id,
            score,
            signal_snapshot: Vec::new(),
            creator_id: None,
            format: None,
        }
    }

    /// Construct a scored candidate with diversity metadata.
    pub fn with_diversity_metadata(
        entity_id: EntityId,
        score: f64,
        creator_id: Option<EntityId>,
        format: Option<String>,
    ) -> Self {
        Self {
            entity_id,
            score,
            signal_snapshot: Vec::new(),
            creator_id,
            format,
        }
    }
}
```

This modification is backward-compatible: `ScoredCandidate::new()` still works, setting both new fields to `None`. Existing m2p3 tests are unaffected.

### Public API

```rust
// === ranking/diversity.rs ===

use crate::schema::EntityId;
use super::executor::ScoredCandidate;
use super::profile::DiversitySpec;

/// Maximum fraction of results from any single format when format_mix is enabled.
/// Spec 09 Section 9.3: "no more than 60% of results from any single format."
pub const DEFAULT_FORMAT_MIX_MAX_FRACTION: f64 = 0.6;

/// Runtime diversity constraints for the selection pass.
///
/// Constructed from the profile's `DiversitySpec` and optionally
/// overridden by the `DIVERSITY` clause in the query. For M2, query-level
/// overrides are wired in m2p5.
///
/// # Spec Reference
///
/// Spec 09 Section 9.1 (DiversitySpec), Section 9.5 (relaxation under pressure).
#[derive(Debug, Clone)]
pub struct DiversityConstraints {
    /// Maximum number of items from the same creator.
    /// None = no creator constraint.
    pub max_per_creator: Option<usize>,

    /// Maximum fraction of results from any single format.
    /// None = no format constraint.
    /// When `format_mix` is true on the profile, this defaults to 0.6.
    pub format_mix_max_fraction: Option<f64>,

    /// Fraction of results from unfollowed creators.
    /// M3 feature -- ignored by the M2 selector.
    pub min_exploration: Option<f64>,

    /// Topic diversity lambda [0.0, 1.0] for MMR.
    /// M6 feature -- ignored by the M2 selector.
    pub topic_diversity: Option<f64>,

    /// Minimum items per represented category.
    /// M6 feature -- ignored by the M2 selector.
    pub category_min: Option<usize>,
}

impl DiversityConstraints {
    /// Create constraints with no diversity enforcement.
    pub fn none() -> Self {
        Self {
            max_per_creator: None,
            format_mix_max_fraction: None,
            min_exploration: None,
            topic_diversity: None,
            category_min: None,
        }
    }

    /// Returns true if any constraint is active.
    pub fn has_constraints(&self) -> bool {
        self.max_per_creator.is_some() || self.format_mix_max_fraction.is_some()
        // topic_diversity and category_min are deferred; do not count them
    }
}

impl From<&DiversitySpec> for DiversityConstraints {
    fn from(spec: &DiversitySpec) -> Self {
        Self {
            max_per_creator: spec.max_per_creator.map(|n| n as usize),
            format_mix_max_fraction: if spec.format_mix {
                Some(DEFAULT_FORMAT_MIX_MAX_FRACTION)
            } else {
                None
            },
            min_exploration: None, // M3 feature
            topic_diversity: spec.topic_diversity,
            category_min: spec.category_min.map(|n| n as usize),
        }
    }
}

/// Describes a constraint that could not be fully satisfied.
///
/// Returned as part of `DiversityResult` when the candidate pool
/// does not contain enough variety to meet all constraints at the
/// requested target count.
#[derive(Debug, Clone, PartialEq)]
pub enum ConstraintViolation {
    /// Fewer unique creators available than needed to fill
    /// `target_count` at `max_per_creator` items each.
    InsufficientCreatorDiversity {
        /// The max_per_creator value that was requested.
        requested: usize,
        /// The number of unique creators in the candidate set.
        available_creators: usize,
    },

    /// A single format exceeds the maximum allowed fraction
    /// even after best-effort selection.
    InsufficientFormatDiversity {
        /// The format that exceeded the threshold.
        format: String,
        /// The actual fraction of that format in the result set.
        fraction: f64,
    },
}

/// The result of a diversity selection pass.
///
/// Always contains `selected.len() <= target_count` candidates.
/// When constraints are relaxed to fill the target, `constraints_satisfied`
/// is false and `violations` describes what could not be satisfied.
#[derive(Debug, Clone)]
pub struct DiversityResult {
    /// The selected candidates, in diversity-adjusted order.
    /// Maintains relative score ordering within constraint groups:
    /// among candidates from the same creator, higher-scored items
    /// are selected before lower-scored ones.
    pub selected: Vec<ScoredCandidate>,

    /// Whether all diversity constraints were fully satisfied.
    /// False when relaxation was needed to reach target_count.
    pub constraints_satisfied: bool,

    /// Descriptions of which constraints could not be met.
    /// Empty when `constraints_satisfied` is true.
    pub violations: Vec<ConstraintViolation>,
}

/// Post-scoring diversity selector.
///
/// Takes a scored candidate list (sorted by score descending from the
/// profile executor) and selects candidates that satisfy diversity
/// constraints. The selector never reduces result count -- when
/// constraints cannot be fully satisfied, it relaxes them in a
/// defined order.
///
/// # Algorithm
///
/// Greedy selection in score order:
/// 1. Walk candidates from highest to lowest score.
/// 2. For each candidate, check if adding it would violate any active constraint.
/// 3. If no violation: add to selected, increment counts.
/// 4. If violation: skip, continue to next candidate.
/// 5. After exhausting candidates, if selected < target: relax and retry.
///
/// # Relaxation Order (M2 hardcoded)
///
/// 1. Double `max_per_creator` (allow more items per creator)
/// 2. Ignore `format_mix` (remove format fraction constraint)
/// 3. Accept anything (fill remaining slots from highest-scored unselected)
///
/// # Complexity
///
/// O(n) per pass where n = candidates.len(). Maximum 4 passes in the
/// worst case (initial + 3 relaxation stages). In practice, relaxation
/// is rare and the algorithm completes in a single pass.
///
/// # Spec Reference
///
/// Spec 09 Section 9.2 (Greedy MMR reranking), Section 9.5 (relaxation).
pub struct DiversitySelector;

impl DiversitySelector {
    /// Select candidates from a scored list, enforcing diversity constraints.
    ///
    /// # Arguments
    ///
    /// * `candidates` -- Scored candidates sorted by score descending.
    ///   Must have `creator_id` and `format` populated for constraints
    ///   that reference them. Missing metadata causes the candidate to
    ///   be treated as unconstrained for that dimension.
    /// * `constraints` -- The diversity constraints to enforce.
    /// * `target_count` -- The number of results to select (typically
    ///   the LIMIT from the query).
    ///
    /// # Returns
    ///
    /// A `DiversityResult` with up to `target_count` candidates selected.
    /// If the input has fewer than `target_count` candidates, all are
    /// returned (diversity cannot conjure items that do not exist).
    ///
    /// # Behavior with missing metadata
    ///
    /// - `creator_id` is `None`: candidate is never constrained by
    ///   `max_per_creator` (treated as a unique creator).
    /// - `format` is `None`: candidate is never constrained by
    ///   `format_mix` (treated as a unique format).
    pub fn select(
        candidates: Vec<ScoredCandidate>,
        constraints: &DiversityConstraints,
        target_count: usize,
    ) -> DiversityResult;
}
```

### Algorithm Implementation Detail

```rust
// === ranking/diversity.rs (internal implementation) ===

use std::collections::HashMap;

impl DiversitySelector {
    pub fn select(
        candidates: Vec<ScoredCandidate>,
        constraints: &DiversityConstraints,
        target_count: usize,
    ) -> DiversityResult {
        // **Fast path behavior (corrected):** Two distinct fast paths:
        // 1. `!constraints.has_constraints()` → return all candidates up to target_count,
        //    `constraints_satisfied: true`. No constraint can be violated if there are no
        //    constraints.
        // 2. `candidates.len() <= target_count` with active constraints → return all
        //    candidates, BUT still call `collect_violations` on the returned set to compute
        //    the accurate `constraints_satisfied` and `violations` fields. The count is
        //    trivially correct (returning everything), but satisfaction must be assessed.
        //
        // Do NOT short-circuit `constraints_satisfied: true` when constraints are active,
        // even if returning fewer items than the target.
        if !constraints.has_constraints() {
            return DiversityResult {
                selected: candidates.into_iter().take(target_count).collect(),
                constraints_satisfied: true,
                violations: Vec::new(),
            };
        }

        if candidates.len() <= target_count {
            let selected: Vec<ScoredCandidate> = candidates.into_iter().take(target_count).collect();
            let mut violations = Vec::new();
            collect_violations(constraints, &selected, &mut violations);
            return DiversityResult {
                constraints_satisfied: violations.is_empty(),
                selected,
                violations,
            };
        }

        // Stage 0: initial pass with original constraints
        let (selected, remaining) = greedy_pass(
            &candidates,
            constraints.max_per_creator,
            constraints.format_mix_max_fraction,
            target_count,
        );

        if selected.len() >= target_count {
            // post_selection_verify: always collect violations before returning,
            // even when Stage 0 fills the target successfully.
            let mut violations = Vec::new();
            collect_violations(constraints, &selected, &mut violations);
            return DiversityResult {
                constraints_satisfied: violations.is_empty(),
                selected,
                violations,
            };
        }

        let mut violations = Vec::new();
        let mut all_selected = selected;

        // Stage 1: relax max_per_creator (double it)
        let relaxed_max = constraints.max_per_creator.map(|n| n * 2);
        let (more, _) = greedy_pass_excluding(
            &candidates,
            &all_selected,
            relaxed_max,
            constraints.format_mix_max_fraction,
            target_count - all_selected.len(),
        );
        all_selected.extend(more);

        if all_selected.len() >= target_count {
            if let Some(requested) = constraints.max_per_creator {
                let unique_creators = count_unique_creators(&all_selected);
                violations.push(ConstraintViolation::InsufficientCreatorDiversity {
                    requested,
                    available_creators: unique_creators,
                });
            }
            return DiversityResult {
                selected: all_selected,
                constraints_satisfied: false,
                violations,
            };
        }

        // Stage 2: ignore format_mix
        let (more, _) = greedy_pass_excluding(
            &candidates,
            &all_selected,
            relaxed_max,
            None, // format constraint removed
            target_count - all_selected.len(),
        );
        all_selected.extend(more);

        if all_selected.len() >= target_count {
            collect_violations(constraints, &all_selected, &mut violations);
            return DiversityResult {
                selected: all_selected,
                constraints_satisfied: false,
                violations,
            };
        }

        // Stage 3: accept anything (fill remaining slots)
        let (more, _) = greedy_pass_excluding(
            &candidates,
            &all_selected,
            None, // no creator constraint
            None, // no format constraint
            target_count - all_selected.len(),
        );
        all_selected.extend(more);

        collect_violations(constraints, &all_selected, &mut violations);
        DiversityResult {
            selected: all_selected,
            constraints_satisfied: false,
            violations,
        }
    }
}

/// Core greedy selection pass. Walks candidates in score order,
/// selects those that satisfy active constraints.
///
/// Returns (selected, indices of selected in the original list).
fn greedy_pass(
    candidates: &[ScoredCandidate],
    max_per_creator: Option<usize>,
    format_mix_max_fraction: Option<f64>,
    target_count: usize,
) -> (Vec<ScoredCandidate>, Vec<usize>) {
    let mut selected = Vec::with_capacity(target_count);
    let mut selected_indices = Vec::with_capacity(target_count);
    let mut creator_counts: HashMap<EntityId, usize> = HashMap::new();
    let mut format_counts: HashMap<String, usize> = HashMap::new();

    for (idx, candidate) in candidates.iter().enumerate() {
        if selected.len() >= target_count {
            break;
        }

        // Check max_per_creator constraint
        if let (Some(max), Some(ref creator_id)) = (max_per_creator, &candidate.creator_id) {
            let count = creator_counts.get(creator_id).copied().unwrap_or(0);
            if count >= max {
                continue; // skip: creator at limit
            }
        }

        // Check format_mix constraint.
        //
        // **Format fraction enforcement (corrected):** Apply format fraction check from the
        // first selected item. Do NOT defer until `current_total >= 5`. The deferral creates
        // a window where small result sets (< 5 items) skip format enforcement entirely,
        // allowing 100% format concentration to pass as satisfied.
        //
        // For small sets where the computed fraction would fluctuate wildly at low counts
        // (e.g., 1 item from 1 format = 100%), the correct behavior is: enforce the
        // constraint and let relaxation handle the case where it cannot be satisfied. If
        // the result is forced to relax, `constraints_satisfied: false` is correctly reported.
        //
        // `post_selection_verify: bool` — **always call `collect_violations` on the final
        // selected set before returning**, even when `Stage 0` fills the target
        // successfully. This ensures `constraints_satisfied` and `violations` are always
        // accurate.
        //
        // The format check is `fraction > max_frac` with no size guard.
        if let (Some(max_frac), Some(ref format)) = (format_mix_max_fraction, &candidate.format) {
            let current_total = selected.len() + 1; // after adding this candidate
            let format_count = format_counts.get(format).copied().unwrap_or(0) + 1;
            let fraction = format_count as f64 / current_total as f64;
            if fraction > max_frac {
                continue; // skip: format would exceed max fraction
            }
        }

        // Candidate passes all constraints -- select it
        if let Some(ref creator_id) = candidate.creator_id {
            *creator_counts.entry(creator_id.clone()).or_insert(0) += 1;
        }
        if let Some(ref format) = candidate.format {
            *format_counts.entry(format.clone()).or_insert(0) += 1;
        }
        selected.push(candidate.clone());
        selected_indices.push(idx);
    }

    (selected, selected_indices)
}

/// Greedy pass that skips already-selected candidates (used in relaxation stages).
fn greedy_pass_excluding(
    candidates: &[ScoredCandidate],
    already_selected: &[ScoredCandidate],
    max_per_creator: Option<usize>,
    format_mix_max_fraction: Option<f64>,
    target_count: usize,
) -> (Vec<ScoredCandidate>, Vec<usize>) {
    // Build set of already-selected entity IDs for O(1) lookup
    let selected_ids: std::collections::HashSet<EntityId> =
        already_selected.iter().map(|c| c.entity_id.clone()).collect();

    // Build current creator/format counts from already-selected
    let mut creator_counts: HashMap<EntityId, usize> = HashMap::new();
    let mut format_counts: HashMap<String, usize> = HashMap::new();
    for candidate in already_selected {
        if let Some(ref creator_id) = candidate.creator_id {
            *creator_counts.entry(creator_id.clone()).or_insert(0) += 1;
        }
        if let Some(ref format) = candidate.format {
            *format_counts.entry(format.clone()).or_insert(0) += 1;
        }
    }

    let mut selected = Vec::with_capacity(target_count);
    let mut selected_indices = Vec::with_capacity(target_count);
    let total_base = already_selected.len();

    for (idx, candidate) in candidates.iter().enumerate() {
        if selected.len() >= target_count {
            break;
        }

        // Skip already-selected candidates
        if selected_ids.contains(&candidate.entity_id) {
            continue;
        }

        // Check max_per_creator constraint
        if let (Some(max), Some(ref creator_id)) = (max_per_creator, &candidate.creator_id) {
            let count = creator_counts.get(creator_id).copied().unwrap_or(0);
            if count >= max {
                continue;
            }
        }

        // Check format_mix constraint (no size guard -- see greedy_pass for rationale).
        if let (Some(max_frac), Some(ref format)) = (format_mix_max_fraction, &candidate.format) {
            let current_total = total_base + selected.len() + 1;
            let format_count = format_counts.get(format).copied().unwrap_or(0) + 1;
            let fraction = format_count as f64 / current_total as f64;
            if fraction > max_frac {
                continue;
            }
        }

        // Candidate passes -- select it
        if let Some(ref creator_id) = candidate.creator_id {
            *creator_counts.entry(creator_id.clone()).or_insert(0) += 1;
        }
        if let Some(ref format) = candidate.format {
            *format_counts.entry(format.clone()).or_insert(0) += 1;
        }
        selected.push(candidate.clone());
        selected_indices.push(idx);
    }

    (selected, selected_indices)
}

/// Count unique creators in a candidate set.
fn count_unique_creators(candidates: &[ScoredCandidate]) -> usize {
    let creators: std::collections::HashSet<&EntityId> = candidates
        .iter()
        .filter_map(|c| c.creator_id.as_ref())
        .collect();
    creators.len()
}

/// Collect constraint violations by comparing the selected set against
/// the original constraints.
fn collect_violations(
    constraints: &DiversityConstraints,
    selected: &[ScoredCandidate],
    violations: &mut Vec<ConstraintViolation>,
) {
    // Check max_per_creator
    if let Some(max) = constraints.max_per_creator {
        let mut creator_counts: HashMap<&EntityId, usize> = HashMap::new();
        for candidate in selected {
            if let Some(ref creator_id) = candidate.creator_id {
                *creator_counts.entry(creator_id).or_insert(0) += 1;
            }
        }
        let exceeds = creator_counts.values().any(|&c| c > max);
        if exceeds {
            let unique = creator_counts.len();
            violations.push(ConstraintViolation::InsufficientCreatorDiversity {
                requested: max,
                available_creators: unique,
            });
        }
    }

    // Check format_mix
    if let Some(max_frac) = constraints.format_mix_max_fraction {
        let mut format_counts: HashMap<&str, usize> = HashMap::new();
        for candidate in selected {
            if let Some(ref format) = candidate.format {
                *format_counts.entry(format.as_str()).or_insert(0) += 1;
            }
        }
        let total = selected.len();
        for (format, count) in &format_counts {
            let fraction = *count as f64 / total as f64;
            if fraction > max_frac {
                violations.push(ConstraintViolation::InsufficientFormatDiversity {
                    format: format.to_string(),
                    fraction,
                });
            }
        }
    }
}
```

### Module Re-exports

```rust
// === ranking/mod.rs (additions) ===

pub mod diversity;

pub use diversity::{
    DiversityConstraints, DiversityResult, DiversitySelector, ConstraintViolation,
    DEFAULT_FORMAT_MIX_MAX_FRACTION,
};
```

### Error Handling

- The diversity selector never returns errors. Unsatisfied constraints produce `constraints_satisfied: false` with `ConstraintViolation` descriptions, not `Result::Err`.
- Missing `creator_id` on a candidate means that candidate is never constrained by `max_per_creator` -- it is treated as if it has a unique creator. This is correct behavior for entities without creator metadata.
- Missing `format` on a candidate means that candidate is never constrained by `format_mix` -- it is treated as if it has a unique format.
- If `target_count` is 0, return an empty `DiversityResult` with `constraints_satisfied: true`.
- If `candidates` is empty, return an empty `DiversityResult` with `constraints_satisfied: true`.

## Test Strategy

### Unit Tests

```rust
// === diversity selector tests ===

#[test]
fn select_no_constraints_returns_top_n() {
    let candidates = make_candidates(10, 10, &["video"]); // 10 candidates, 10 creators
    let constraints = DiversityConstraints::none();
    let result = DiversitySelector::select(candidates.clone(), &constraints, 5);

    assert_eq!(result.selected.len(), 5);
    assert!(result.constraints_satisfied);
    assert!(result.violations.is_empty());
    // Should be top 5 by score
    for (i, candidate) in result.selected.iter().enumerate() {
        assert_eq!(candidate.entity_id, candidates[i].entity_id);
    }
}

#[test]
fn select_max_per_creator_enforced() {
    // 10 candidates from 2 creators (5 each), max_per_creator = 2
    let candidates = make_candidates_multi_creator(10, 2, &["video"]);
    let constraints = DiversityConstraints {
        max_per_creator: Some(2),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 4);

    assert_eq!(result.selected.len(), 4);
    assert!(result.constraints_satisfied);

    // No creator should have more than 2 items
    let mut creator_counts: HashMap<EntityId, usize> = HashMap::new();
    for c in &result.selected {
        if let Some(ref id) = c.creator_id {
            *creator_counts.entry(id.clone()).or_insert(0) += 1;
        }
    }
    for count in creator_counts.values() {
        assert!(*count <= 2, "creator has {} items, max is 2", count);
    }
}

#[test]
fn select_max_per_creator_one() {
    // 20 candidates from 5 creators (4 each), max_per_creator = 1
    let candidates = make_candidates_multi_creator(20, 5, &["video"]);
    let constraints = DiversityConstraints {
        max_per_creator: Some(1),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 5);

    assert_eq!(result.selected.len(), 5);
    assert!(result.constraints_satisfied);

    // Each creator should have exactly 1 item
    let mut creator_counts: HashMap<EntityId, usize> = HashMap::new();
    for c in &result.selected {
        if let Some(ref id) = c.creator_id {
            *creator_counts.entry(id.clone()).or_insert(0) += 1;
        }
    }
    assert_eq!(creator_counts.len(), 5, "should have 5 unique creators");
    for count in creator_counts.values() {
        assert_eq!(*count, 1);
    }
}

#[test]
fn select_format_mix_enforced() {
    // 20 candidates: 15 video, 5 article
    let mut candidates = make_candidates(15, 15, &["video"]);
    candidates.extend(make_candidates_with_offset(5, 5, &["article"], 15));
    // Sort by score descending (videos have higher scores)
    candidates.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());

    let constraints = DiversityConstraints {
        format_mix_max_fraction: Some(0.6),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 10);

    assert_eq!(result.selected.len(), 10);

    // No format should exceed 60%
    let mut format_counts: HashMap<&str, usize> = HashMap::new();
    for c in &result.selected {
        if let Some(ref fmt) = c.format {
            *format_counts.entry(fmt.as_str()).or_insert(0) += 1;
        }
    }
    let total = result.selected.len() as f64;
    for (fmt, count) in &format_counts {
        let fraction = *count as f64 / total;
        assert!(fraction <= 0.6 + f64::EPSILON,
            "format '{}' has fraction {:.2}, exceeds 0.60", fmt, fraction);
    }
}

#[test]
fn select_combined_constraints() {
    // 30 candidates: 3 creators x 10 items, 2 formats
    let mut candidates = Vec::new();
    for creator in 0..3u64 {
        for item in 0..10u64 {
            let idx = creator * 10 + item;
            let format = if item % 2 == 0 { "video" } else { "article" };
            candidates.push(ScoredCandidate::with_diversity_metadata(
                EntityId::new(idx),
                (30.0 - idx as f64) * 0.1, // decreasing score
                Some(EntityId::new(100 + creator)),
                Some(format.to_string()),
            ));
        }
    }
    candidates.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap());

    let constraints = DiversityConstraints {
        max_per_creator: Some(2),
        format_mix_max_fraction: Some(0.6),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 6);

    assert_eq!(result.selected.len(), 6);

    // Max 2 per creator
    let mut creator_counts: HashMap<EntityId, usize> = HashMap::new();
    for c in &result.selected {
        if let Some(ref id) = c.creator_id {
            *creator_counts.entry(id.clone()).or_insert(0) += 1;
        }
    }
    for count in creator_counts.values() {
        assert!(*count <= 2);
    }
}

#[test]
fn select_relaxation_insufficient_creators() {
    // 10 candidates all from 1 creator, max_per_creator = 1, target = 5
    let candidates = make_candidates_single_creator(10, EntityId::new(99), &["video"]);
    let constraints = DiversityConstraints {
        max_per_creator: Some(1),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 5);

    // Should still return 5 items after relaxation
    assert_eq!(result.selected.len(), 5);
    // But constraints were not fully satisfied
    assert!(!result.constraints_satisfied);
    assert!(!result.violations.is_empty());
    // Should contain an InsufficientCreatorDiversity violation
    assert!(result.violations.iter().any(|v| matches!(v,
        ConstraintViolation::InsufficientCreatorDiversity { .. }
    )));
}

#[test]
fn select_relaxation_insufficient_format_diversity() {
    // 20 candidates all format "video", format_mix max 0.6, target = 10
    let candidates = make_candidates(20, 20, &["video"]);
    let constraints = DiversityConstraints {
        format_mix_max_fraction: Some(0.6),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 10);

    // Should return 10 items after relaxation
    assert_eq!(result.selected.len(), 10);
    assert!(!result.constraints_satisfied);
    assert!(result.violations.iter().any(|v| matches!(v,
        ConstraintViolation::InsufficientFormatDiversity { .. }
    )));
}

#[test]
fn select_fewer_candidates_than_target() {
    let candidates = make_candidates(3, 3, &["video"]);
    let constraints = DiversityConstraints {
        max_per_creator: Some(1),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 10);

    assert_eq!(result.selected.len(), 3);
    assert!(result.constraints_satisfied);
}

#[test]
fn select_empty_candidates() {
    let result = DiversitySelector::select(
        Vec::new(),
        &DiversityConstraints::none(),
        10,
    );
    assert!(result.selected.is_empty());
    assert!(result.constraints_satisfied);
}

#[test]
fn select_target_zero() {
    let candidates = make_candidates(10, 10, &["video"]);
    let result = DiversitySelector::select(
        candidates,
        &DiversityConstraints::none(),
        0,
    );
    assert!(result.selected.is_empty());
    assert!(result.constraints_satisfied);
}

#[test]
fn select_preserves_score_order_within_creator() {
    // 6 candidates from 2 creators, max_per_creator = 2
    // Creator A: scores 1.0, 0.8, 0.6
    // Creator B: scores 0.9, 0.7, 0.5
    let candidates = vec![
        ScoredCandidate::with_diversity_metadata(EntityId::new(0), 1.0, Some(EntityId::new(100)), Some("video".into())),
        ScoredCandidate::with_diversity_metadata(EntityId::new(1), 0.9, Some(EntityId::new(101)), Some("video".into())),
        ScoredCandidate::with_diversity_metadata(EntityId::new(2), 0.8, Some(EntityId::new(100)), Some("video".into())),
        ScoredCandidate::with_diversity_metadata(EntityId::new(3), 0.7, Some(EntityId::new(101)), Some("video".into())),
        ScoredCandidate::with_diversity_metadata(EntityId::new(4), 0.6, Some(EntityId::new(100)), Some("video".into())),
        ScoredCandidate::with_diversity_metadata(EntityId::new(5), 0.5, Some(EntityId::new(101)), Some("video".into())),
    ];

    let constraints = DiversityConstraints {
        max_per_creator: Some(2),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 4);

    assert_eq!(result.selected.len(), 4);
    // Creator 100 should have items 0 (score 1.0) and 2 (score 0.8) -- top 2 by score
    let creator_100: Vec<_> = result.selected.iter()
        .filter(|c| c.creator_id == Some(EntityId::new(100)))
        .collect();
    assert_eq!(creator_100.len(), 2);
    assert_eq!(creator_100[0].entity_id, EntityId::new(0));
    assert_eq!(creator_100[1].entity_id, EntityId::new(2));
}

#[test]
fn select_none_creator_id_unconstrained() {
    // Candidates without creator_id should not be constrained by max_per_creator
    let candidates = vec![
        ScoredCandidate::with_diversity_metadata(EntityId::new(0), 1.0, None, Some("video".into())),
        ScoredCandidate::with_diversity_metadata(EntityId::new(1), 0.9, None, Some("video".into())),
        ScoredCandidate::with_diversity_metadata(EntityId::new(2), 0.8, None, Some("video".into())),
    ];
    let constraints = DiversityConstraints {
        max_per_creator: Some(1),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 3);

    // All 3 should be selected -- None creator_id is unconstrained
    assert_eq!(result.selected.len(), 3);
    assert!(result.constraints_satisfied);
}

#[test]
fn select_none_format_unconstrained() {
    // Candidates without format should not be constrained by format_mix
    let candidates = vec![
        ScoredCandidate::with_diversity_metadata(EntityId::new(0), 1.0, Some(EntityId::new(100)), None),
        ScoredCandidate::with_diversity_metadata(EntityId::new(1), 0.9, Some(EntityId::new(101)), None),
        ScoredCandidate::with_diversity_metadata(EntityId::new(2), 0.8, Some(EntityId::new(102)), None),
    ];
    let constraints = DiversityConstraints {
        format_mix_max_fraction: Some(0.6),
        ..DiversityConstraints::none()
    };
    let result = DiversitySelector::select(candidates, &constraints, 3);

    assert_eq!(result.selected.len(), 3);
    assert!(result.constraints_satisfied);
}

#[test]
fn from_diversity_spec() {
    let spec = DiversitySpec {
        max_per_creator: Some(3),
        format_mix: true,
        topic_diversity: Some(0.5),
        category_min: Some(2),
    };
    let constraints = DiversityConstraints::from(&spec);

    assert_eq!(constraints.max_per_creator, Some(3));
    assert_eq!(constraints.format_mix_max_fraction, Some(DEFAULT_FORMAT_MIX_MAX_FRACTION));
    assert!(constraints.min_exploration.is_none()); // M3 feature
    assert_eq!(constraints.topic_diversity, Some(0.5)); // stored but not enforced in M2
    assert_eq!(constraints.category_min, Some(2)); // stored but not enforced in M2
}

#[test]
fn from_diversity_spec_no_format_mix() {
    let spec = DiversitySpec {
        max_per_creator: Some(2),
        format_mix: false,
        topic_diversity: None,
        category_min: None,
    };
    let constraints = DiversityConstraints::from(&spec);

    assert_eq!(constraints.max_per_creator, Some(2));
    assert!(constraints.format_mix_max_fraction.is_none());
}

#[test]
fn has_constraints_returns_false_when_none() {
    let constraints = DiversityConstraints::none();
    assert!(!constraints.has_constraints());
}

#[test]
fn has_constraints_returns_true_with_max_per_creator() {
    let constraints = DiversityConstraints {
        max_per_creator: Some(2),
        ..DiversityConstraints::none()
    };
    assert!(constraints.has_constraints());
}

#[test]
fn has_constraints_returns_true_with_format_mix() {
    let constraints = DiversityConstraints {
        format_mix_max_fraction: Some(0.6),
        ..DiversityConstraints::none()
    };
    assert!(constraints.has_constraints());
}
```

### Test Helpers

```rust
/// Create N candidates with unique creators and the given format(s).
/// Scores decrease from N*0.1 to 0.1.
fn make_candidates(n: usize, num_creators: usize, formats: &[&str]) -> Vec<ScoredCandidate> {
    (0..n).map(|i| {
        let creator = EntityId::new((i % num_creators) as u64 + 100);
        let format = formats[i % formats.len()].to_string();
        ScoredCandidate::with_diversity_metadata(
            EntityId::new(i as u64),
            (n - i) as f64 * 0.1,
            Some(creator),
            Some(format),
        )
    }).collect()
}

/// Create candidates with an entity ID offset (for combining multiple sets).
fn make_candidates_with_offset(
    n: usize, num_creators: usize, formats: &[&str], offset: usize,
) -> Vec<ScoredCandidate> {
    (0..n).map(|i| {
        let creator = EntityId::new((i % num_creators + offset) as u64 + 100);
        let format = formats[i % formats.len()].to_string();
        ScoredCandidate::with_diversity_metadata(
            EntityId::new((i + offset) as u64),
            (n - i) as f64 * 0.05, // lower scores than main set
            Some(creator),
            Some(format),
        )
    }).collect()
}

/// Create candidates with multiple creators, evenly distributed.
fn make_candidates_multi_creator(
    n: usize, num_creators: usize, formats: &[&str],
) -> Vec<ScoredCandidate> {
    make_candidates(n, num_creators, formats)
}

/// Create candidates all from a single creator.
fn make_candidates_single_creator(
    n: usize, creator_id: EntityId, formats: &[&str],
) -> Vec<ScoredCandidate> {
    (0..n).map(|i| {
        let format = formats[i % formats.len()].to_string();
        ScoredCandidate::with_diversity_metadata(
            EntityId::new(i as u64),
            (n - i) as f64 * 0.1,
            Some(creator_id.clone()),
            Some(format),
        )
    }).collect()
}
```

## Acceptance Criteria

- [ ] `DiversityConstraints` struct with `max_per_creator: Option<usize>`, `format_mix_max_fraction: Option<f64>`, `min_exploration: Option<f64>`, `topic_diversity: Option<f64>`, `category_min: Option<usize>`
- [ ] `DiversityConstraints::none()` constructor for no-constraint default
- [ ] `DiversityConstraints::has_constraints()` returns true only for M2-implemented constraints
- [ ] `From<&DiversitySpec>` impl converts profile config to runtime constraints; `format_mix: true` maps to `DEFAULT_FORMAT_MIX_MAX_FRACTION` (0.6)
- [ ] `DiversityResult` struct with `selected: Vec<ScoredCandidate>`, `constraints_satisfied: bool`, `violations: Vec<ConstraintViolation>`
- [ ] `ConstraintViolation::InsufficientCreatorDiversity` with `requested` and `available_creators`
- [ ] `ConstraintViolation::InsufficientFormatDiversity` with `format` and `fraction`
- [ ] `DiversitySelector::select()` takes `Vec<ScoredCandidate>`, `&DiversityConstraints`, `target_count` and returns `DiversityResult`
- [ ] `max_per_creator` enforced: no more than N items from any single creator in the selected set
- [ ] `format_mix` enforced: no format exceeds 60% of the selected set (when `format_mix_max_fraction` is `Some(0.6)`)
- [ ] Format fraction enforcement applied from the first selected item (no grace period); post-selection `collect_violations` always called before returning
- [ ] Diversity pass does not reduce result count: `selected.len()` equals `min(target_count, candidates.len())`
- [ ] Three-stage relaxation: (1) double `max_per_creator`, (2) ignore `format_mix`, (3) accept anything
- [ ] When relaxation is needed, `constraints_satisfied == false` and `violations` is non-empty
- [ ] Candidates with `creator_id: None` are unconstrained by `max_per_creator`
- [ ] Candidates with `format: None` are unconstrained by `format_mix`
- [ ] Empty input returns empty result with `constraints_satisfied: true`
- [ ] Target count 0 returns empty result with `constraints_satisfied: true`
- [ ] `ScoredCandidate` extended with `creator_id: Option<EntityId>` and `format: Option<String>`
- [ ] `ScoredCandidate::new()` backward-compatible (new fields default to `None`)
- [ ] `ScoredCandidate::with_diversity_metadata()` constructor for test and executor use
- [ ] Score order preserved within same-creator groups: higher-scored items selected before lower-scored ones
- [ ] `topic_diversity` and `category_min` fields exist on `DiversityConstraints` but are ignored by selector with `tracing::debug!` when set
- [ ] `min_exploration` field exists but is ignored with `todo!()` comment referencing M3
- [ ] `pub mod diversity` added to `ranking/mod.rs` with appropriate re-exports
- [ ] No `unsafe` code
- [ ] `cargo clippy -- -D warnings` passes
- [ ] All unit tests pass

## Spec References

- [docs/specs/09-ranking-scoring.md](../../../specs/09-ranking-scoring.md) -- Section 9.1 (DiversitySpec), Section 9.2 (Greedy MMR algorithm), Section 9.3 (Constraint details), Section 9.4 (Per-page diversity), Section 9.5 (Reordering not filtering, relaxation), Section 16 (INV-RANK-5: diversity never reduces result count)

## Implementation Notes

- `EntityId` must implement `Hash` + `Eq` for use as `HashMap` keys. It already does (m1p1).
- The `DiversitySpec` type is already defined in `ranking/profile.rs` (m2p3 Task 01) with `max_per_creator: Option<u32>`, `format_mix: bool`, `topic_diversity: Option<f64>`, `category_min: Option<u32>`. The `DiversityConstraints` type in this task is a runtime representation -- it converts `u32` to `usize` and `format_mix: bool` to `format_mix_max_fraction: Option<f64>`.
- The `ScoredCandidate` modification (adding `creator_id` and `format`) is a breaking change to the struct layout but NOT a breaking change to the API because `ScoredCandidate::new()` preserves its signature. Existing tests that construct `ScoredCandidate` via `new()` work without modification.
- For M2, the RETRIEVE executor (m2p5) populates `creator_id` and `format` from entity metadata when constructing the candidate set. The profile executor (m2p3) does not populate these fields -- it is the RETRIEVE executor's responsibility because it already loads entity metadata for filtering.
- The three-stage relaxation is simple and effective for M2. A more sophisticated relaxation policy (configurable stage order, partial relaxation) can be added in M6 if needed.
- The `greedy_pass_excluding` function is slightly wasteful because it re-walks the full candidate list. At 200 candidates this is negligible. For M7 scale (10K+ candidates), the selection algorithm may need a priority queue. That is a performance optimization, not a correctness concern.
- Do NOT implement `topic_diversity` (MMR) in this task. MMR requires embedding distance computation between each candidate and the already-selected set, which changes the algorithm from O(n) to O(n*k). This is deferred to M6.
- Do NOT implement `category_min` in this task. It requires category metadata on each candidate and a different selection strategy (ensure minimum representation). Deferred to M6.
