Skip to main content

wadachi_spec/
interp.rs

1//! The frecency interpreter — walks a [`FrecencyRankingSpec`]'s phases over a
2//! set of [`DirEntry`] candidates and returns them ranked. The clock is the
3//! only side effect, supplied via [`FrecencyEnvironment`], so the whole thing
4//! is deterministic under test.
5
6use std::cmp::Ordering;
7
8use chrono::NaiveDateTime;
9
10use crate::env::FrecencyEnvironment;
11use crate::spec::{DirEntry, FrecencyRankingSpec, RankPhase, RankedDir};
12
13/// A typed interpreter failure. Every unimplemented or out-of-order phase
14/// surfaces here — never a silent wrong answer.
15#[derive(Debug, thiserror::Error, PartialEq, Eq)]
16pub enum SpecError {
17    /// A phase ran against a working set that a prior phase should have set up.
18    #[error("frecency interpreter failed at phase `{phase}`: {reason}")]
19    Interp {
20        /// The phase that failed.
21        phase: String,
22        /// Why it failed.
23        reason: String,
24    },
25}
26
27/// Per-entry accumulator threaded through the phases.
28struct Acc {
29    path: std::path::PathBuf,
30    discovered_only: bool,
31    freq: usize,
32    visits: Vec<NaiveDateTime>,
33    ages: Vec<f64>,
34    decayed: Vec<f64>,
35    score: f64,
36}
37
38/// Rank `entries` according to `spec`, using `env` for the current time.
39///
40/// # Errors
41/// Returns [`SpecError::Interp`] if the phase pipeline is malformed (e.g. a
42/// compute phase runs before `LoadEntries`).
43// `entries` is deliberately by-value: the published API hands ownership to
44// the interpreter, and a spec may legally contain `LoadEntries` more than
45// once (each load re-seeds from the same input). Switching to `&[DirEntry]`
46// would be a breaking change to a crates.io-published signature.
47#[allow(clippy::needless_pass_by_value)]
48pub fn apply(
49    spec: &FrecencyRankingSpec,
50    entries: Vec<DirEntry>,
51    env: &impl FrecencyEnvironment,
52) -> Result<Vec<RankedDir>, SpecError> {
53    let now = env.now();
54    let mut working: Option<Vec<Acc>> = None;
55
56    for phase in &spec.phases {
57        match phase {
58            RankPhase::LoadEntries => {
59                working = Some(
60                    entries
61                        .iter()
62                        .map(|e| Acc {
63                            path: e.path.clone(),
64                            discovered_only: e.discovered_only,
65                            freq: e.visits.len(),
66                            visits: e.visits.clone(),
67                            ages: Vec::new(),
68                            decayed: Vec::new(),
69                            score: 0.0,
70                        })
71                        .collect(),
72                );
73            }
74            RankPhase::ComputeAge => {
75                let set = require(working.as_mut(), "ComputeAge")?;
76                for acc in set.iter_mut() {
77                    acc.ages = acc
78                        .visits
79                        .iter()
80                        .map(|t| age_days(now, *t))
81                        .collect();
82                }
83            }
84            RankPhase::ApplyDecay => {
85                let set = require(working.as_mut(), "ApplyDecay")?;
86                for acc in set.iter_mut() {
87                    acc.decayed = acc
88                        .ages
89                        .iter()
90                        .map(|a| spec.decay.decay(*a, spec.half_life_days))
91                        .collect();
92                }
93            }
94            RankPhase::Combine => {
95                let set = require(working.as_mut(), "Combine")?;
96                for acc in set.iter_mut() {
97                    let recency: f64 = acc.decayed.iter().sum();
98                    #[allow(clippy::cast_precision_loss)]
99                    let freq = acc.freq as f64;
100                    acc.score = spec.recency_weight * recency + spec.freq_weight * freq;
101                }
102            }
103            RankPhase::FloorIndexed => {
104                let set = require(working.as_mut(), "FloorIndexed")?;
105                for acc in set.iter_mut() {
106                    if acc.discovered_only {
107                        acc.score = spec.indexed_epsilon;
108                    }
109                }
110            }
111            RankPhase::SortDesc => {
112                let set = require(working.as_mut(), "SortDesc")?;
113                set.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(Ordering::Equal));
114            }
115            RankPhase::TopK { n } => {
116                let set = require(working.as_mut(), "TopK")?;
117                set.truncate(*n);
118            }
119        }
120    }
121
122    let set = working.ok_or_else(|| SpecError::Interp {
123        phase: "LoadEntries".to_owned(),
124        reason: "spec had no LoadEntries phase — nothing to rank".to_owned(),
125    })?;
126
127    Ok(set
128        .into_iter()
129        .map(|acc| RankedDir {
130            path: acc.path,
131            score: acc.score,
132        })
133        .collect())
134}
135
136fn require<'a>(set: Option<&'a mut Vec<Acc>>, phase: &str) -> Result<&'a mut Vec<Acc>, SpecError> {
137    set.ok_or_else(|| SpecError::Interp {
138        phase: phase.to_owned(),
139        reason: "phase ran before `LoadEntries` seeded the working set".to_owned(),
140    })
141}
142
143fn age_days(now: NaiveDateTime, then: NaiveDateTime) -> f64 {
144    let secs = (now - then).num_seconds();
145    #[allow(clippy::cast_precision_loss)]
146    let days = secs as f64 / 86_400.0;
147    days
148}