feat(db): add opt-in _ZO_RANKING_MODE=recency

Adds a new environment variable that lets users opt out of the
frecency-weighted ordering in favour of plain "most recently used"
ordering, addressing #1231.

- frecency (default) keeps the existing rank-times-recency-bucket score
- recency uses last_accessed directly, so popularity from months ago
  never beats a directory you just visited

The displayed `--score` value stays the frecency value in both modes so
existing tooling around `zoxide query --score` is unaffected, and the
on-disk format is unchanged.

Signed-off-by: ChrisJr404 <chris@hacknow.com>
This commit is contained in:
ChrisJr404 2026-05-10 16:26:43 -04:00
parent 1683e7b8ed
commit f8886563dd
No known key found for this signature in database
8 changed files with 100 additions and 8 deletions

View File

@ -426,6 +426,13 @@ Environment variables[^2] can be used for configuration. They must be set before
- Configures the [aging algorithm][algorithm-aging], which limits the maximum - Configures the [aging algorithm][algorithm-aging], which limits the maximum
number of entries in the database. number of entries in the database.
- By default, this is set to 10000. - By default, this is set to 10000.
- `_ZO_RANKING_MODE`
- Selects the ranking function used when sorting query results:
- `frecency` (default): combines frequency and recency, as described in
[Algorithm][algorithm-frecency].
- `recency`: sorts purely by `last_accessed`, so the most recently visited
match always wins regardless of how often older directories were used.
- The value is case-insensitive. Unrecognized values fall back to `frecency`.
- `_ZO_RESOLVE_SYMLINKS` - `_ZO_RESOLVE_SYMLINKS`
- When set to 1, `z` will resolve symlinks before adding directories to the - When set to 1, `z` will resolve symlinks before adding directories to the
database. database.
@ -469,6 +476,7 @@ Environment variables[^2] can be used for configuration. They must be set before
[alfred]: https://www.alfredapp.com/ [alfred]: https://www.alfredapp.com/
[alfred-zoxide]: https://github.com/yihou/alfred-zoxide [alfred-zoxide]: https://github.com/yihou/alfred-zoxide
[algorithm-aging]: https://github.com/ajeetdsouza/zoxide/wiki/Algorithm#aging [algorithm-aging]: https://github.com/ajeetdsouza/zoxide/wiki/Algorithm#aging
[algorithm-frecency]: https://github.com/ajeetdsouza/zoxide/wiki/Algorithm#frecency
[algorithm-matching]: https://github.com/ajeetdsouza/zoxide/wiki/Algorithm#matching [algorithm-matching]: https://github.com/ajeetdsouza/zoxide/wiki/Algorithm#matching
[alpine linux packages]: https://pkgs.alpinelinux.org/packages?name=zoxide [alpine linux packages]: https://pkgs.alpinelinux.org/packages?name=zoxide
[arch linux extra]: https://archlinux.org/packages/extra/x86_64/zoxide/ [arch linux extra]: https://archlinux.org/packages/extra/x86_64/zoxide/

View File

@ -96,6 +96,13 @@ manpage for the full list of options.
Configures the aging algorithm, which limits the maximum number of entries in Configures the aging algorithm, which limits the maximum number of entries in
the database. By default, this is set to 10000. the database. By default, this is set to 10000.
.TP .TP
.B _ZO_RANKING_MODE
Selects the ranking function used when sorting query results. Accepted values
are \fBfrecency\fR (default) and \fBrecency\fR. With \fBrecency\fR, results are
sorted purely by \fBlast_accessed\fR so the most recently visited match always
wins. The value is case-insensitive; unrecognized values fall back to
\fBfrecency\fR.
.TP
.B _ZO_RESOLVE_SYMLINKS .B _ZO_RESOLVE_SYMLINKS
When set to 1, \fBz\fR will resolve symlinks before adding directories to When set to 1, \fBz\fR will resolve symlinks before adding directories to
the database. the database.

View File

@ -3,6 +3,7 @@ use std::io::{self, Write};
use anyhow::Result; use anyhow::Result;
use crate::cmd::{Edit, EditCommand, Run}; use crate::cmd::{Edit, EditCommand, Run};
use crate::config;
use crate::db::Database; use crate::db::Database;
use crate::error::BrokenPipeHandler; use crate::error::BrokenPipeHandler;
use crate::util::{self, Fzf, FzfChild}; use crate::util::{self, Fzf, FzfChild};
@ -32,7 +33,7 @@ impl Run for Edit {
Ok(()) Ok(())
} }
None => { None => {
db.sort_by_score(now); db.sort_by_score(now, config::ranking_mode());
db.save()?; db.save()?;
Self::get_fzf()?.wait()?; Self::get_fzf()?.wait()?;
Ok(()) Ok(())

View File

@ -80,7 +80,8 @@ impl Query {
let mut options = StreamOptions::new(now) let mut options = StreamOptions::new(now)
.with_keywords(self.keywords.iter().map(|s| s.as_str())) .with_keywords(self.keywords.iter().map(|s| s.as_str()))
.with_exclude(config::exclude_dirs()?) .with_exclude(config::exclude_dirs()?)
.with_base_dir(self.base_dir.clone()); .with_base_dir(self.base_dir.clone())
.with_ranking_mode(config::ranking_mode());
if !self.all { if !self.all {
let resolve_symlinks = config::resolve_symlinks(); let resolve_symlinks = config::resolve_symlinks();
options = options.with_exists(true).with_resolve_symlinks(resolve_symlinks); options = options.with_exists(true).with_resolve_symlinks(resolve_symlinks);

View File

@ -7,6 +7,13 @@ use glob::Pattern;
use crate::db::Rank; use crate::db::Rank;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum RankingMode {
#[default]
Frecency,
Recency,
}
pub fn data_dir() -> Result<PathBuf> { pub fn data_dir() -> Result<PathBuf> {
let dir = match env::var_os("_ZO_DATA_DIR") { let dir = match env::var_os("_ZO_DATA_DIR") {
Some(path) => PathBuf::from(path), Some(path) => PathBuf::from(path),
@ -60,3 +67,10 @@ pub fn maxage() -> Result<Rank> {
pub fn resolve_symlinks() -> bool { pub fn resolve_symlinks() -> bool {
env::var_os("_ZO_RESOLVE_SYMLINKS").is_some_and(|var| var == "1") env::var_os("_ZO_RESOLVE_SYMLINKS").is_some_and(|var| var == "1")
} }
pub fn ranking_mode() -> RankingMode {
match env::var_os("_ZO_RANKING_MODE") {
Some(var) if var.eq_ignore_ascii_case("recency") => RankingMode::Recency,
_ => RankingMode::Frecency,
}
}

View File

@ -3,6 +3,7 @@ use std::fmt::{self, Display, Formatter};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::config::RankingMode;
use crate::util::{DAY, HOUR, WEEK}; use crate::util::{DAY, HOUR, WEEK};
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
@ -18,7 +19,14 @@ impl Dir<'_> {
DirDisplay::new(self) DirDisplay::new(self)
} }
pub fn score(&self, now: Epoch) -> Rank { pub fn score(&self, now: Epoch, mode: RankingMode) -> Rank {
match mode {
RankingMode::Frecency => self.frecency(now),
RankingMode::Recency => self.last_accessed as Rank,
}
}
pub(crate) fn frecency(&self, now: Epoch) -> Rank {
// The older the entry, the lesser its importance. // The older the entry, the lesser its importance.
let duration = now.saturating_sub(self.last_accessed); let duration = now.saturating_sub(self.last_accessed);
if duration < HOUR { if duration < HOUR {
@ -58,7 +66,9 @@ impl<'a> DirDisplay<'a> {
impl Display for DirDisplay<'_> { impl Display for DirDisplay<'_> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
if let Some(now) = self.now { if let Some(now) = self.now {
let score = self.dir.score(now).clamp(0.0, 9999.0); // Always display the frecency value so that `--score` stays
// human-readable regardless of the active ranking mode.
let score = self.dir.frecency(now).clamp(0.0, 9999.0);
write!(f, "{score:>6.1}{}", self.separator)?; write!(f, "{score:>6.1}{}", self.separator)?;
} }
write!(f, "{}", self.dir.path) write!(f, "{}", self.dir.path)
@ -67,3 +77,43 @@ impl Display for DirDisplay<'_> {
pub type Rank = f64; pub type Rank = f64;
pub type Epoch = u64; pub type Epoch = u64;
#[cfg(test)]
mod tests {
use super::*;
use crate::util::HOUR;
fn dir(path: &'static str, rank: Rank, last_accessed: Epoch) -> Dir<'static> {
Dir { path: Cow::Borrowed(path), rank, last_accessed }
}
#[test]
fn frecency_mode_prefers_high_rank() {
let now = 10 * HOUR;
// Same age bucket (>1h, <1d). Higher rank wins.
let popular = dir("/popular", 50.0, now - 2 * HOUR);
let recent = dir("/recent", 1.0, now - 2 * HOUR);
assert!(
popular.score(now, RankingMode::Frecency) > recent.score(now, RankingMode::Frecency)
);
}
#[test]
fn recency_mode_prefers_recent_access() {
let now = 10 * HOUR;
let popular_but_old = dir("/popular", 50.0, now - 5 * HOUR);
let unpopular_but_recent = dir("/recent", 1.0, now - HOUR);
assert!(
unpopular_but_recent.score(now, RankingMode::Recency)
> popular_but_old.score(now, RankingMode::Recency)
);
}
#[test]
fn recency_mode_ignores_rank() {
let now = 10 * HOUR;
let a = dir("/a", 1.0, now - HOUR);
let b = dir("/b", 9999.0, now - HOUR);
assert_eq!(a.score(now, RankingMode::Recency), b.score(now, RankingMode::Recency));
}
}

View File

@ -8,9 +8,10 @@ use anyhow::{Context, Result, bail};
use bincode::Options; use bincode::Options;
use ouroboros::self_referencing; use ouroboros::self_referencing;
use crate::config::{self, RankingMode};
pub use crate::db::dir::{Dir, Epoch, Rank}; pub use crate::db::dir::{Dir, Epoch, Rank};
pub use crate::db::stream::{Stream, StreamOptions}; pub use crate::db::stream::{Stream, StreamOptions};
use crate::{config, util}; use crate::util;
#[self_referencing] #[self_referencing]
pub struct Database { pub struct Database {
@ -171,10 +172,10 @@ impl Database {
self.with_dirty_mut(|dirty| *dirty = true); self.with_dirty_mut(|dirty| *dirty = true);
} }
pub fn sort_by_score(&mut self, now: Epoch) { pub fn sort_by_score(&mut self, now: Epoch, mode: RankingMode) {
self.with_dirs_mut(|dirs| { self.with_dirs_mut(|dirs| {
dirs.sort_unstable_by(|dir1: &Dir, dir2: &Dir| { dirs.sort_unstable_by(|dir1: &Dir, dir2: &Dir| {
dir1.score(now).total_cmp(&dir2.score(now)) dir1.score(now, mode).total_cmp(&dir2.score(now, mode))
}) })
}); });
self.with_dirty_mut(|dirty| *dirty = true); self.with_dirty_mut(|dirty| *dirty = true);

View File

@ -5,6 +5,7 @@ use std::{fs, path};
use glob::Pattern; use glob::Pattern;
use crate::config::RankingMode;
use crate::db::{Database, Dir, Epoch}; use crate::db::{Database, Dir, Epoch};
use crate::util::{self, MONTH}; use crate::util::{self, MONTH};
@ -16,7 +17,7 @@ pub struct Stream<'a> {
impl<'a> Stream<'a> { impl<'a> Stream<'a> {
pub fn new(db: &'a mut Database, options: StreamOptions) -> Self { pub fn new(db: &'a mut Database, options: StreamOptions) -> Self {
db.sort_by_score(options.now); db.sort_by_score(options.now, options.ranking_mode);
let idxs = (0..db.dirs().len()).rev(); let idxs = (0..db.dirs().len()).rev();
Stream { db, idxs, options } Stream { db, idxs, options }
} }
@ -129,6 +130,9 @@ pub struct StreamOptions {
/// Only return directories within this parent directory /// Only return directories within this parent directory
/// Does not check if the path exists /// Does not check if the path exists
base_dir: Option<String>, base_dir: Option<String>,
/// Which scoring function to use when sorting directories.
ranking_mode: RankingMode,
} }
impl StreamOptions { impl StreamOptions {
@ -141,9 +145,15 @@ impl StreamOptions {
resolve_symlinks: false, resolve_symlinks: false,
ttl: now.saturating_sub(3 * MONTH), ttl: now.saturating_sub(3 * MONTH),
base_dir: None, base_dir: None,
ranking_mode: RankingMode::default(),
} }
} }
pub fn with_ranking_mode(mut self, ranking_mode: RankingMode) -> Self {
self.ranking_mode = ranking_mode;
self
}
pub fn with_keywords<I>(mut self, keywords: I) -> Self pub fn with_keywords<I>(mut self, keywords: I) -> Self
where where
I: IntoIterator, I: IntoIterator,