1use std::cmp::Ordering;
7
8use chrono::NaiveDateTime;
9
10use crate::env::FrecencyEnvironment;
11use crate::spec::{DirEntry, FrecencyRankingSpec, RankPhase, RankedDir};
12
13#[derive(Debug, thiserror::Error, PartialEq, Eq)]
16pub enum SpecError {
17 #[error("frecency interpreter failed at phase `{phase}`: {reason}")]
19 Interp {
20 phase: String,
22 reason: String,
24 },
25}
26
27struct 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#[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}