//! 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.
//!
//! * **hours** — a place whose OSM opening hours say it's closed at the meal
//! time is out too. Unknown or unreadable hours count as open.
//!
//! `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, NaiveDateTime, NaiveTime};
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>,
/// Raw OSM `opening_hours`, if known.
pub hours: Option<String>,
}
#[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>,
/// Its hours say it's closed at the meal time.
pub closed: bool,
}
impl Scored {
/// Out of the draw, for a veto or for being closed.
pub fn excluded(&self) -> bool {
self.closed || !self.vetoed_by.is_empty()
}
}
/// Score every candidate for a meal at `at`, `group_size` people going.
/// Sorted best first, excluded places last.
pub fn score(candidates: &[Candidate], group_size: usize, at: NaiveDateTime) -> Vec<Scored> {
let on = at.date();
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 closed = crate::hours::closed_at(c.hours.as_deref(), at);
let score = if vetoed_by.is_empty() && !closed { 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,
closed,
}
})
.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.excluded()
.cmp(&b.excluded())
.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 at
/// `meal_time`, 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,
meal_time: NaiveTime,
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.and_time(meal_time));
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 noon(d: u32) -> NaiveDateTime {
day(d).and_hms_opt(12, 0, 0).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(),
hours: None,
}
}
#[test]
fn never_is_a_veto() {
let c = [cand("Loved but vetoed", &[("a", Rank::Always), ("b", Rank::Never)], &[])];
let s = score(&c, 2, noon(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, noon(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 closed_at_meal_time_is_out() {
let mut weekdays = cand("weekdays", &[("a", Rank::Obsessed { since: day(1) })], &[]);
weekdays.hours = Some("Mo-Fr 11:00-15:00".into());
let mut unreadable = cand("unreadable", &[], &[]);
unreadable.hours = Some("ask nicely".into());
let c = [weekdays, unreadable];
// Monday noon: open, and the obsession leads.
let s = score(&c, 1, noon(26));
assert_eq!(s[0].name, "weekdays");
assert!(!s[0].closed);
// Saturday noon: closed, so out of the draw; unreadable stays in.
let s = score(&c, 1, noon(31));
assert_eq!(s[0].name, "unreadable");
assert!(s[1].closed && s[1].excluded() && s[1].score == 0.0);
assert_eq!(draw(&s, 0.99).unwrap().name, "unreadable");
}
#[test]
fn unranked_counts_as_neutral() {
let c = [cand("x", &[("a", Rank::Always)], &[])];
// (4 + 1 + 1) / 3
assert!((score(&c, 3, noon(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, noon(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, noon(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, noon(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, noon(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, noon(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), NaiveTime::from_hms_opt(12, 0, 0).unwrap(), 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));
}
}
neiam
/fieri-forever
the flavortown lunch engine
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
Log in to mark this repository.
12.7 KiB · text
History
6dd7389