11.1 KiB · text History 504ebaf
//! Where should we eat? Pure scoring and picking, no database — handlers load
//! the inputs, this decides.
//!
//! For a set of people going out, each restaurant gets:
//!
//! * **veto** — anyone going ranked it `Never`: it's out, whatever else.
//! * **preference** — the mean of the group's weights (`Obsessed` 6, `Always`
//!   4, `Positive` 2, `Neutral` 1). Nobody has to rank everything: unranked is `Neutral`.
//! * **recency** — `1 / (1 + 3 × load)`, where load sums every past visit
//!   decayed by a 10-day half-life. Yesterday's spot scores about a quarter of
//!   a fresh one; a place last visited a month ago is mostly forgiven.
//!
//! `score = preference × recency`, and the pick is a weighted draw on score, so
//! a strong favourite still wins most days without winning every day.
//!
//! [`draw_many`] fills a ballot: several distinct options for one meal, drawn
//! the same weighted way, skipping any the caller has already shown.
//!
//! [`plan`] picks several meals in a row (a week of lunches, or a trip) by
//! treating each pick as a visit before scoring the next, so a plan doesn't
//! repeat itself unless the area is very short on options.
use std::collections::HashSet;

use chrono::NaiveDate;
use serde::Serialize;
use uuid::Uuid;

use crate::entities::rankings::Rank;

pub const HALF_LIFE_DAYS: f64 = 10.0;
/// Visits older than this no longer count at all (their decayed weight is
/// already under 1%).
pub const HISTORY_WINDOW_DAYS: i64 = 70;
const RECENCY_STRENGTH: f64 = 3.0;

pub fn weight(rank: Rank) -> f64 {
    match rank {
        Rank::Obsessed { .. } => 6.0,
        Rank::Always => 4.0,
        Rank::Positive => 2.0,
        Rank::Neutral => 1.0,
        Rank::Never => 0.0,
    }
}

/// One restaurant, as the group going to lunch sees it.
#[derive(Debug, Clone)]
pub struct Candidate {
    pub id: Uuid,
    pub name: String,
    /// Ranks from the people going, by name. People who haven't ranked it are
    /// simply absent and count as `Neutral`.
    pub ranks: Vec<(String, Rank)>,
    /// Past visits by anyone — the team-level "we were just there".
    pub visits: Vec<NaiveDate>,
}

#[derive(Debug, Clone, Serialize)]
pub struct Scored {
    pub id: Uuid,
    pub name: String,
    pub preference: f64,
    pub recency: f64,
    pub score: f64,
    /// Chance of being drawn, 0–1, among the non-vetoed.
    pub probability: f64,
    pub last_visit_days: Option<i64>,
    /// Going and ranked it Always or Obsessed.
    pub fans: Vec<String>,
    pub vetoed_by: Vec<String>,
}

/// Score every candidate for a meal on `on`, `group_size` people going.
/// Sorted best first, vetoed places last.
pub fn score(candidates: &[Candidate], group_size: usize, on: NaiveDate) -> Vec<Scored> {
    let group_size = group_size.max(1) as f64;
    let mut scored: Vec<Scored> = candidates
        .iter()
        .map(|c| {
            let vetoed_by: Vec<String> =
                c.ranks.iter().filter(|(_, r)| *r == Rank::Never).map(|(n, _)| n.clone()).collect();
            let fans: Vec<String> =
                c.ranks
                .iter()
                .filter(|(_, r)| matches!(r, Rank::Always | Rank::Obsessed { .. }))
                .map(|(n, _)| n.clone())
                .collect();

            let ranked_total: f64 = c.ranks.iter().map(|(_, r)| weight(*r)).sum();
            let unranked = (group_size - c.ranks.len() as f64).max(0.0);
            let preference = (ranked_total + unranked * weight(Rank::Neutral)) / group_size;

            let ages: Vec<i64> = c
                .visits
                .iter()
                .map(|v| (on - *v).num_days())
                .filter(|d| (0..=HISTORY_WINDOW_DAYS).contains(d))
                .collect();
            let load: f64 = ages.iter().map(|d| 0.5f64.powf(*d as f64 / HALF_LIFE_DAYS)).sum();
            let recency = 1.0 / (1.0 + RECENCY_STRENGTH * load);

            let score = if vetoed_by.is_empty() { preference * recency } else { 0.0 };
            Scored {
                id: c.id,
                name: c.name.clone(),
                preference,
                recency,
                score,
                probability: 0.0,
                last_visit_days: c.visits.iter().map(|v| (on - *v).num_days()).filter(|d| *d >= 0).min(),
                fans,
                vetoed_by,
            }
        })
        .collect();

    let total: f64 = scored.iter().map(|s| s.score).sum();
    if total > 0.0 {
        for s in &mut scored {
            s.probability = s.score / total;
        }
    }
    scored.sort_by(|a, b| {
        a.vetoed_by
            .is_empty()
            .cmp(&b.vetoed_by.is_empty())
            .reverse()
            .then(b.score.total_cmp(&a.score))
            .then_with(|| a.name.cmp(&b.name))
    });
    scored
}

/// Weighted draw over `scored` using `r` uniform in [0, 1). `None` when
/// everything is vetoed (or there's nothing to pick from).
pub fn draw(scored: &[Scored], r: f64) -> Option<&Scored> {
    let total: f64 = scored.iter().map(|s| s.score).sum();
    if total <= 0.0 {
        return None;
    }
    let mut target = r.clamp(0.0, 1.0) * total;
    for s in scored.iter().filter(|s| s.score > 0.0) {
        if target < s.score {
            return Some(s);
        }
        target -= s.score;
    }
    // Float rounding at r ≈ 1: fall back to the last eligible entry.
    scored.iter().rev().find(|s| s.score > 0.0)
}

/// Up to `k` distinct options, each a weighted draw from what's left, never
/// one in `exclude` and never a vetoed one. Fewer than `k` when the field
/// runs out.
pub fn draw_many(
    field: &[Scored],
    k: usize,
    exclude: &HashSet<Uuid>,
    mut rng: impl FnMut() -> f64,
) -> Vec<Scored> {
    let mut pool: Vec<Scored> =
        field.iter().filter(|s| s.score > 0.0 && !exclude.contains(&s.id)).cloned().collect();
    let mut out = Vec::with_capacity(k);
    while out.len() < k {
        let Some(id) = draw(&pool, rng()).map(|s| s.id) else { break };
        let i = pool.iter().position(|s| s.id == id).expect("drawn from pool");
        out.push(pool.swap_remove(i));
    }
    out
}

#[derive(Debug, Clone, Serialize)]
pub struct PlannedMeal {
    pub on: NaiveDate,
    pub pick: Option<Scored>,
    /// The full field for this meal, so the page can show the odds.
    pub field: Vec<Scored>,
}

/// Plan `meals` meals on consecutive days starting `start`, each pick counting
/// as a visit for the ones after it. `rng` yields uniforms in [0, 1).
pub fn plan(
    candidates: &[Candidate],
    group_size: usize,
    start: NaiveDate,
    meals: usize,
    mut rng: impl FnMut() -> f64,
) -> Vec<PlannedMeal> {
    let mut working = candidates.to_vec();
    let mut out = Vec::with_capacity(meals);
    for i in 0..meals {
        let on = start + chrono::Days::new(i as u64);
        let field = score(&working, group_size, on);
        let pick = draw(&field, rng()).cloned();
        if let Some(p) = &pick
            && let Some(c) = working.iter_mut().find(|c| c.id == p.id)
        {
            c.visits.push(on);
        }
        out.push(PlannedMeal { on, pick, field });
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    fn day(d: u32) -> NaiveDate {
        NaiveDate::from_ymd_opt(2026, 10, d).unwrap()
    }

    fn cand(name: &str, ranks: &[(&str, Rank)], visits: &[NaiveDate]) -> Candidate {
        Candidate {
            id: Uuid::new_v4(),
            name: name.into(),
            ranks: ranks.iter().map(|(n, r)| (n.to_string(), *r)).collect(),
            visits: visits.to_vec(),
        }
    }

    #[test]
    fn never_is_a_veto() {
        let c = [cand("Loved but vetoed", &[("a", Rank::Always), ("b", Rank::Never)], &[])];
        let s = score(&c, 2, day(28));
        assert_eq!(s[0].score, 0.0);
        assert_eq!(s[0].vetoed_by, vec!["b".to_string()]);
        assert!(draw(&s, 0.5).is_none());
    }

    #[test]
    fn obsession_outweighs_always_but_not_a_veto() {
        let since = day(1);
        let c = [
            cand("obsessed", &[("a", Rank::Obsessed { since })], &[]),
            cand("always", &[("a", Rank::Always)], &[]),
            cand("vetoed", &[("a", Rank::Obsessed { since }), ("b", Rank::Never)], &[]),
        ];
        let s = score(&c, 2, day(28));
        assert_eq!(s[0].name, "obsessed");
        // (6 + 1) / 2 vs (4 + 1) / 2
        assert!((s[0].preference - 3.5).abs() < 1e-9);
        assert_eq!(s[0].fans, vec!["a".to_string()]);
        assert_eq!(s.last().unwrap().score, 0.0);
    }

    #[test]
    fn unranked_counts_as_neutral() {
        let c = [cand("x", &[("a", Rank::Always)], &[])];
        // (4 + 1 + 1) / 3
        assert!((score(&c, 3, day(28))[0].preference - 2.0).abs() < 1e-9);
    }

    #[test]
    fn recent_visits_drag_a_place_down() {
        let c = [
            cand("yesterday", &[("a", Rank::Always)], &[day(27)]),
            cand("fresh", &[("a", Rank::Positive)], &[]),
        ];
        let s = score(&c, 1, day(28));
        // 4 × 1/(1 + 3 × 0.933) ≈ 1.05 vs 2 × 1 — the favourite yields.
        assert_eq!(s[0].name, "fresh");
        assert_eq!(s[1].last_visit_days, Some(1));
    }

    #[test]
    fn old_visits_are_forgiven() {
        let c = [cand("x", &[], &[day(28) - chrono::Days::new(HISTORY_WINDOW_DAYS as u64 + 1)])];
        assert_eq!(score(&c, 1, day(28))[0].recency, 1.0);
    }

    #[test]
    fn probabilities_sum_to_one() {
        let c = [cand("a", &[], &[]), cand("b", &[("x", Rank::Always)], &[day(20)]), cand("c", &[], &[day(27)])];
        let total: f64 = score(&c, 1, day(28)).iter().map(|s| s.probability).sum();
        assert!((total - 1.0).abs() < 1e-9);
    }

    #[test]
    fn draw_walks_the_cumulative_weights() {
        let c = [cand("a", &[], &[]), cand("b", &[], &[])];
        let s = score(&c, 1, day(28));
        assert_eq!(draw(&s, 0.0).unwrap().name, s[0].name);
        assert_eq!(draw(&s, 0.99).unwrap().name, s[1].name);
        assert_eq!(draw(&s, 1.0).unwrap().name, s[1].name);
    }

    #[test]
    fn draw_many_is_distinct_and_honours_exclusions() {
        let mut c: Vec<_> = (0..5).map(|i| cand(&format!("r{i}"), &[], &[])).collect();
        c.push(cand("vetoed", &[("a", Rank::Never)], &[]));
        let field = score(&c, 1, day(28));
        let exclude: HashSet<Uuid> = [c[0].id].into();
        let mut r = [0.9, 0.1, 0.5, 0.0, 0.7].into_iter().cycle();
        let picks = draw_many(&field, 10, &exclude, || r.next().unwrap());
        // 6 candidates, one excluded, one vetoed: 4 left, all different.
        assert_eq!(picks.len(), 4);
        let ids: HashSet<Uuid> = picks.iter().map(|p| p.id).collect();
        assert_eq!(ids.len(), 4);
        assert!(!ids.contains(&c[0].id) && !ids.contains(&c[5].id));
    }

    #[test]
    fn a_plan_does_not_repeat_when_it_has_options() {
        let c: Vec<_> = (0..5).map(|i| cand(&format!("r{i}"), &[], &[])).collect();
        // Always draw the top of the field: the deterministic worst case for
        // repeats, since the leader is whatever wasn't just picked.
        let meals = plan(&c, 1, day(1), 5, || 0.0);
        let mut picked: Vec<_> = meals.iter().map(|m| m.pick.as_ref().unwrap().name.clone()).collect();
        picked.sort();
        picked.dedup();
        assert_eq!(picked.len(), 5);
        assert_eq!(meals[4].on, day(5));
    }
}