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:
parent
1683e7b8ed
commit
f8886563dd
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue