Frecency: usage-aware ranking across all palettes

FrecencyStore (palette lib, Qt::Core, 11 tests): per-id {count,last}
persisted as JSON at $XDG_CONFIG_HOME/olliepalette/frecency.json via
QSaveFile. bonus() = recency bucket x capped visit count, in [0,100] —
below the substring tier (400) so it nudges/tie-breaks without overriding
a stronger textual match, and floats habitual choices up on an empty
query.

Plugin: OllieView loads the store, records every activation (M-x
runAction + file/symbol switcher lambdas) and sets PaletteItem::frecency
before showing each palette. File/symbol frecency keys are
project-qualified so usage does not bleed between repos.
This commit is contained in:
Levi Neely 2026-10-07 21:00:00 +02:00
parent cf0cf7e950
commit 7195a6c11b
7 changed files with 402 additions and 5 deletions

View File

@ -86,8 +86,9 @@ Therefore the palette is replaced, not extended, and the replacement is built on
and feeds them to `PaletteWidget`. The action's current shortcut is shown in and feeds them to `PaletteWidget`. The action's current shortcut is shown in
the group column. Accepting a row triggers the underlying `QAction`. the group column. Accepting a row triggers the underlying `QAction`.
- Installs to `kf6/ktexteditor`; metadata verified valid via `KPluginMetaData`. - Installs to `kf6/ktexteditor`; metadata verified valid via `KPluginMetaData`.
- Not yet wired: frecency (the model supports a bonus, but the plugin currently - Frecency: wired. `OllieView` holds a persisted `FrecencyStore`; activations
passes 0 for every action — usage tracking is deferred). are recorded and `PaletteItem::frecency` is populated for every palette (see
the Frecency entry under M5).
- Duplicate handling: actions are deduplicated only by pointer identity (the - Duplicate handling: actions are deduplicated only by pointer identity (the
exact same action object reachable through several GUI clients is listed exact same action object reachable through several GUI clients is listed
once — functionally lossless). Distinct actions are never dropped. When two once — functionally lossless). Distinct actions are never dropped. When two
@ -225,6 +226,22 @@ Therefore the palette is replaced, not extended, and the replacement is built on
1-based → cursor 0-based). Also in the M-x palette (`ollie:switch:symbol`) 1-based → cursor 0-based). Also in the M-x palette (`ollie:switch:symbol`)
and reachable from a radial by objectName `ollie_goto_symbol`. and reachable from a radial by objectName `ollie_goto_symbol`.
(Alt+R is reserved for the radial key trigger, so this uses Alt+G.) (Alt+R is reserved for the radial key trigger, so this uses Alt+G.)
- Frecency (usage-aware ranking) — DONE.
`src/palette/frecencystore.{h,cpp}` (in `palette`, Qt::Core only, 11 unit
tests):
- Per-id `{count, last-used}` persisted as flat JSON (via `QSaveFile`) at
`$XDG_CONFIG_HOME/olliepalette/frecency.json`. `bonus(id, now)` combines a
recency bucket (<1d=100, <1w=70, <1mo=50, <3mo=30, else 10) with a visit
count capped at 10 → an integer in [0,100]. The cap keeps the bonus well
below a textual-match tier (substring=400) so frecency nudges ordering and
breaks ties without overriding a clearly better match; on an empty query it
floats habitual choices to the top.
- Wired into the plugin: `OllieView` loads the store in its ctor, every
activation (`runAction` for the M-x palette; the file/symbol switcher
lambdas) calls `recordUsage` (bump + save), and each palette sets
`PaletteItem::frecency` from `bonus()` before showing. File/symbol keys are
**project-qualified** (prefixed by the resolved root) so usage never bleeds
between repos that share relative paths.
## Build and test ## Build and test

View File

@ -3,6 +3,8 @@ add_library(palette STATIC
palettemodel.h palettemodel.h
palettewidget.cpp palettewidget.cpp
palettewidget.h palettewidget.h
frecencystore.cpp
frecencystore.h
) )
target_link_libraries(palette PUBLIC fuzzyranker Qt6::Core Qt6::Gui Qt6::Widgets) target_link_libraries(palette PUBLIC fuzzyranker Qt6::Core Qt6::Gui Qt6::Widgets)
target_include_directories(palette PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_include_directories(palette PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
@ -11,4 +13,8 @@ if(Qt6Test_FOUND)
add_executable(test_palettemodel test_palettemodel.cpp) add_executable(test_palettemodel test_palettemodel.cpp)
target_link_libraries(test_palettemodel PRIVATE palette Qt6::Test Qt6::Widgets) target_link_libraries(test_palettemodel PRIVATE palette Qt6::Test Qt6::Widgets)
add_test(NAME palettemodel COMMAND test_palettemodel) add_test(NAME palettemodel COMMAND test_palettemodel)
add_executable(test_frecencystore test_frecencystore.cpp)
target_link_libraries(test_frecencystore PRIVATE palette Qt6::Test)
add_test(NAME frecencystore COMMAND test_frecencystore)
endif() endif()

View File

@ -0,0 +1,135 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "frecencystore.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonValue>
#include <QSaveFile>
namespace katecustom
{
namespace
{
constexpr qint64 kDay = 24 * 60 * 60;
} // namespace
FrecencyStore::FrecencyStore(const QString &filePath)
: m_filePath(filePath)
{
}
bool FrecencyStore::load()
{
m_entries.clear();
if (m_filePath.isEmpty()) {
return false;
}
QFile f(m_filePath);
if (!f.open(QIODevice::ReadOnly)) {
return false;
}
const QByteArray data = f.readAll();
f.close();
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(data, &err);
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
return false;
}
const QJsonObject root = doc.object();
for (auto it = root.constBegin(); it != root.constEnd(); ++it) {
if (!it.value().isObject()) {
continue;
}
const QJsonObject o = it.value().toObject();
Entry e;
e.count = o.value(QStringLiteral("count")).toInt();
e.last = static_cast<qint64>(o.value(QStringLiteral("last")).toDouble());
if (e.count > 0) {
m_entries.insert(it.key(), e);
}
}
return true;
}
bool FrecencyStore::save() const
{
if (m_filePath.isEmpty()) {
return false;
}
const QFileInfo info(m_filePath);
if (!QDir().mkpath(info.absolutePath())) {
return false;
}
QJsonObject root;
for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) {
QJsonObject o;
o.insert(QStringLiteral("count"), it.value().count);
o.insert(QStringLiteral("last"), static_cast<double>(it.value().last));
root.insert(it.key(), o);
}
QSaveFile f(m_filePath);
if (!f.open(QIODevice::WriteOnly)) {
return false;
}
f.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
return f.commit();
}
void FrecencyStore::bump(const QString &id, qint64 nowEpoch)
{
if (id.isEmpty()) {
return;
}
Entry &e = m_entries[id];
++e.count;
e.last = nowEpoch;
}
int FrecencyStore::computeBonus(const Entry &e, qint64 nowEpoch)
{
if (e.count <= 0) {
return 0;
}
// Recency bucket: how long since last use. Newer buckets weigh more. A
// negative age (clock skew / future timestamp) is treated as "just now".
const qint64 age = nowEpoch - e.last;
int recency;
if (age < kDay) {
recency = 100;
} else if (age < 7 * kDay) {
recency = 70;
} else if (age < 30 * kDay) {
recency = 50;
} else if (age < 90 * kDay) {
recency = 30;
} else {
recency = 10;
}
// Frequency factor: 1..10 visits scale the recency weight linearly, so a
// single recent use scores 10 and ten-plus recent uses saturate at the full
// recency weight (100). Capped so the bonus never dwarfs a textual-match
// tier (substring = 400) — frecency nudges, it does not override.
const int visits = qBound(1, e.count, 10);
return (recency * visits) / 10;
}
int FrecencyStore::bonus(const QString &id, qint64 nowEpoch) const
{
auto it = m_entries.constFind(id);
if (it == m_entries.constEnd()) {
return 0;
}
return computeBonus(it.value(), nowEpoch);
}
} // namespace katecustom

View File

@ -0,0 +1,72 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* FrecencyStore — persisted per-id usage ("frecency": frequency + recency)
* feeding the palette's ranking bonus.
*
* Each entry records how many times an id was activated and when it was last
* used. bonus() combines a recency bucket with a capped visit count into a
* small integer (0..100) that PaletteModel adds to the match score — enough to
* float habitual choices to the top on an empty query and to break ties among
* comparable matches, but capped well below a strong textual-match tier so it
* never overrides a clearly better match.
*
* Persistence is a flat JSON object at an explicit file path (so tests use a
* temp file); the plugin picks the real path from QStandardPaths. The class is
* Qt::Core only and has no display or event-loop dependency.
*/
#ifndef KATECUSTOM_FRECENCYSTORE_H
#define KATECUSTOM_FRECENCYSTORE_H
#include <QHash>
#include <QString>
namespace katecustom
{
class FrecencyStore
{
public:
struct Entry {
int count = 0; // number of recorded activations
qint64 last = 0; // last activation, seconds since epoch (UTC)
};
/*! Construct backed by \a filePath. The file is not read until load(). */
explicit FrecencyStore(const QString &filePath = QString());
/*! Replace the backing file path (does not reload). */
void setFilePath(const QString &filePath) { m_filePath = filePath; }
QString filePath() const { return m_filePath; }
/*! Read entries from the backing file. Missing/invalid file clears to empty
* and is not an error. Returns true if a file was read. */
bool load();
/*! Write entries to the backing file, creating parent dirs. Returns true on
* success. */
bool save() const;
/*! Record one activation of \a id at \a nowEpoch (UTC seconds): increments
* the count and sets the last-used time. */
void bump(const QString &id, qint64 nowEpoch);
/*! Ranking bonus for \a id at \a nowEpoch, in [0, 100]. Unknown ids yield 0.
* Combines a recency bucket (newer = higher) with a capped visit count. */
int bonus(const QString &id, qint64 nowEpoch) const;
/*! Direct entry access (empty Entry if unknown). */
Entry entry(const QString &id) const { return m_entries.value(id); }
int size() const { return m_entries.size(); }
/*! Pure scoring used by bonus(); exposed for testing. */
static int computeBonus(const Entry &e, qint64 nowEpoch);
private:
QString m_filePath;
QHash<QString, Entry> m_entries;
};
} // namespace katecustom
#endif

View File

@ -0,0 +1,115 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
*
* Unit tests for FrecencyStore: bump/bonus scoring (recency buckets, frequency
* scaling, caps) and JSON round-trip persistence to a temp file.
*/
#include "frecencystore.h"
#include <QDir>
#include <QTemporaryDir>
#include <QTest>
using namespace katecustom;
namespace
{
constexpr qint64 kDay = 24 * 60 * 60;
constexpr qint64 kNow = 1'700'000'000; // fixed reference epoch
} // namespace
class TestFrecencyStore : public QObject
{
Q_OBJECT
private Q_SLOTS:
void unknownIdYieldsZero()
{
FrecencyStore s;
QCOMPARE(s.bonus(QStringLiteral("nope"), kNow), 0);
}
void singleRecentUseScoresTen()
{
FrecencyStore s;
s.bump(QStringLiteral("a"), kNow);
// recency 100 (<1 day), 1 visit -> 100*1/10 = 10.
QCOMPARE(s.bonus(QStringLiteral("a"), kNow), 10);
}
void frequentRecentUseSaturatesAtHundred()
{
FrecencyStore s;
for (int i = 0; i < 15; ++i) {
s.bump(QStringLiteral("a"), kNow);
}
// visits capped at 10 -> 100*10/10 = 100.
QCOMPARE(s.bonus(QStringLiteral("a"), kNow), 100);
}
void recencyDecaysWithAge()
{
FrecencyStore s;
s.bump(QStringLiteral("a"), kNow - 10 * kDay); // 10 days ago
// 10 days -> <30 day bucket (50), 1 visit -> 5.
QCOMPARE(s.bonus(QStringLiteral("a"), kNow), 5);
FrecencyStore s2;
s2.bump(QStringLiteral("a"), kNow - 200 * kDay); // very old
// >90 days -> bucket 10, 1 visit -> 1.
QCOMPARE(s2.bonus(QStringLiteral("a"), kNow), 1);
}
void recentBeatsOldAtEqualCount()
{
FrecencyStore s;
s.bump(QStringLiteral("recent"), kNow);
s.bump(QStringLiteral("old"), kNow - 60 * kDay);
QVERIFY(s.bonus(QStringLiteral("recent"), kNow)
> s.bonus(QStringLiteral("old"), kNow));
}
void futureTimestampTreatedAsNow()
{
FrecencyStore s;
s.bump(QStringLiteral("a"), kNow + 5 * kDay); // clock skew
QCOMPARE(s.bonus(QStringLiteral("a"), kNow), 10); // age<0 -> recency 100
}
void roundTripsThroughFile()
{
QTemporaryDir tmp;
QVERIFY(tmp.isValid());
const QString path = QDir(tmp.path()).filePath(QStringLiteral("sub/frecency.json"));
FrecencyStore a(path);
a.bump(QStringLiteral("x"), kNow);
a.bump(QStringLiteral("x"), kNow);
a.bump(QStringLiteral("y"), kNow - 40 * kDay);
QVERIFY(a.save()); // creates the "sub" dir
FrecencyStore b(path);
QVERIFY(b.load());
QCOMPARE(b.size(), 2);
QCOMPARE(b.entry(QStringLiteral("x")).count, 2);
QCOMPARE(b.entry(QStringLiteral("x")).last, kNow);
QCOMPARE(b.bonus(QStringLiteral("x"), kNow), 20); // 100*2/10
}
void loadMissingFileIsEmptyNotError()
{
FrecencyStore s(QStringLiteral("/no/such/dir/frecency.json"));
QVERIFY(!s.load()); // nothing read
QCOMPARE(s.size(), 0);
}
void emptyPathSaveAndLoadFail()
{
FrecencyStore s;
QVERIFY(!s.save());
QVERIFY(!s.load());
}
};
QTEST_MAIN(TestFrecencyStore)
#include "test_frecencystore.moc"

View File

@ -4,6 +4,7 @@
#include "ollieplugin.h" #include "ollieplugin.h"
#include "palettewidget.h" #include "palettewidget.h"
#include "frecencystore.h"
#include "radialmenu.h" #include "radialmenu.h"
#include "radialconfig.h" #include "radialconfig.h"
#include "olliecommands.h" #include "olliecommands.h"
@ -24,6 +25,7 @@
#include <QAction> #include <QAction>
#include <QApplication> #include <QApplication>
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QEvent> #include <QEvent>
#include <QFileInfo> #include <QFileInfo>
@ -32,6 +34,7 @@
#include <QKeySequence> #include <QKeySequence>
#include <QMouseEvent> #include <QMouseEvent>
#include <QSet> #include <QSet>
#include <QStandardPaths>
#include <QUrl> #include <QUrl>
#include <QWidget> #include <QWidget>
@ -215,6 +218,14 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
: QObject(plugin) : QObject(plugin)
, m_mainWindow(mainWindow) , m_mainWindow(mainWindow)
{ {
// Load persisted usage so frecency ranking is available from the first
// palette invocation. The store lives under the user's generic config dir.
const QString cfgDir =
QStandardPaths::writableLocation(QStandardPaths::GenericConfigLocation);
m_frecency.setFilePath(QDir(cfgDir).filePath(
QStringLiteral("olliepalette/frecency.json")));
m_frecency.load();
// The one keyboard door: Alt+X (M-x). A single modifier, no chord, and the // The one keyboard door: Alt+X (M-x). A single modifier, no chord, and the
// keys sit together on a compact layout. // keys sit together on a compact layout.
auto *openAction = new QAction(i18n("Command Palette (M-x)"), this); auto *openAction = new QAction(i18n("Command Palette (M-x)"), this);
@ -440,6 +451,12 @@ void OllieView::showCommandPalette()
items.push_back(item); items.push_back(item);
} }
// Apply the persisted frecency bonus so habitual commands rank higher.
const qint64 now = QDateTime::currentSecsSinceEpoch();
for (PaletteItem &item : items) {
item.frecency = m_frecency.bonus(item.id, now);
}
m_palette->setItems(items); m_palette->setItems(items);
// Center the palette near the top of the active window. // Center the palette near the top of the active window.
@ -453,8 +470,21 @@ void OllieView::showCommandPalette()
m_palette->activate(); m_palette->activate();
} }
void OllieView::recordUsage(const QString &id)
{
if (id.isEmpty()) {
return;
}
m_frecency.bump(id, QDateTime::currentSecsSinceEpoch());
m_frecency.save();
}
void OllieView::runAction(const QString &actionId) void OllieView::runAction(const QString &actionId)
{ {
// Count every command-palette activation toward frecency (verbs, switcher
// entries, and harvested actions all flow through here).
recordUsage(actionId);
// A ":"-verb entry: execute the command directly on the active view. // A ":"-verb entry: execute the command directly on the active view.
// We call OllieCommands::runVerb rather than Editor::queryCommand because // We call OllieCommands::runVerb rather than Editor::queryCommand because
// our Command's auto-registration is not reliably visible to queryCommand. // our Command's auto-registration is not reliably visible to queryCommand.
@ -492,13 +522,19 @@ void OllieView::showFileSwitcher()
connect(m_filePalette, &PaletteWidget::activatedId, this, connect(m_filePalette, &PaletteWidget::activatedId, this,
[this](const QString &id) { [this](const QString &id) {
if (id.startsWith(kFilePrefix)) { if (id.startsWith(kFilePrefix)) {
openProjectFile(id.mid(QString(kFilePrefix).size())); const QString rel = id.mid(QString(kFilePrefix).size());
// Record under a project-qualified key so frecency does
// not bleed between repos that share relative paths.
recordUsage(kFilePrefix + m_fileSwitcherRoot
+ QLatin1Char('\t') + rel);
openProjectFile(rel);
} }
}); });
} }
m_fileSwitcherRoot = projectRootFor(m_mainWindow); m_fileSwitcherRoot = projectRootFor(m_mainWindow);
const QStringList files = ProjectIndex::listFiles(m_fileSwitcherRoot); const QStringList files = ProjectIndex::listFiles(m_fileSwitcherRoot);
const qint64 now = QDateTime::currentSecsSinceEpoch();
QList<PaletteItem> items; QList<PaletteItem> items;
items.reserve(files.size()); items.reserve(files.size());
@ -511,6 +547,8 @@ void OllieView::showFileSwitcher()
// the whole path. The base name still ranks well because an exact or // the whole path. The base name still ranks well because an exact or
// substring hit on it outscores a scattered subsequence. // substring hit on it outscores a scattered subsequence.
item.label = rel; item.label = rel;
item.frecency = m_frecency.bonus(
kFilePrefix + m_fileSwitcherRoot + QLatin1Char('\t') + rel, now);
items.push_back(item); items.push_back(item);
} }
m_filePalette->setItems(items); m_filePalette->setItems(items);
@ -544,7 +582,11 @@ void OllieView::showSymbolSwitcher()
connect(m_symbolPalette, &PaletteWidget::activatedId, this, connect(m_symbolPalette, &PaletteWidget::activatedId, this,
[this](const QString &id) { [this](const QString &id) {
if (id.startsWith(kSymbolPrefix)) { if (id.startsWith(kSymbolPrefix)) {
goToSymbol(id.mid(QString(kSymbolPrefix).size())); const QString loc = id.mid(QString(kSymbolPrefix).size());
// Project-qualified key, as for the file switcher.
recordUsage(kSymbolPrefix + m_symbolSwitcherRoot
+ QLatin1Char('\t') + loc);
goToSymbol(loc);
} }
}); });
} }
@ -552,6 +594,7 @@ void OllieView::showSymbolSwitcher()
m_symbolSwitcherRoot = projectRootFor(m_mainWindow); m_symbolSwitcherRoot = projectRootFor(m_mainWindow);
const QStringList files = ProjectIndex::listFiles(m_symbolSwitcherRoot); const QStringList files = ProjectIndex::listFiles(m_symbolSwitcherRoot);
const QList<Symbol> symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files); const QList<Symbol> symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files);
const qint64 now = QDateTime::currentSecsSinceEpoch();
QList<PaletteItem> items; QList<PaletteItem> items;
items.reserve(symbols.size()); items.reserve(symbols.size());
@ -559,7 +602,8 @@ void OllieView::showSymbolSwitcher()
PaletteItem item; PaletteItem item;
// The id encodes the jump target as "relpath\tline"; the location is // The id encodes the jump target as "relpath\tline"; the location is
// resolved against m_symbolSwitcherRoot on activation. // resolved against m_symbolSwitcherRoot on activation.
item.id = kSymbolPrefix + s.file + QLatin1Char('\t') + QString::number(s.line); const QString loc = s.file + QLatin1Char('\t') + QString::number(s.line);
item.id = kSymbolPrefix + loc;
// Label: scope-qualified name, then kind + file as visible provenance. // Label: scope-qualified name, then kind + file as visible provenance.
// The delegate renders only the label and FuzzyRanker scores against it, // The delegate renders only the label and FuzzyRanker scores against it,
@ -578,6 +622,8 @@ void OllieView::showSymbolSwitcher()
label += QStringLiteral(" — ") + suffix; label += QStringLiteral(" — ") + suffix;
} }
item.label = label; item.label = label;
item.frecency = m_frecency.bonus(
kSymbolPrefix + m_symbolSwitcherRoot + QLatin1Char('\t') + loc, now);
items.push_back(item); items.push_back(item);
} }

View File

@ -13,6 +13,8 @@
#include <KTextEditor/Plugin> #include <KTextEditor/Plugin>
#include "frecencystore.h"
#include <QObject> #include <QObject>
#include <QPointer> #include <QPointer>
#include <QHash> #include <QHash>
@ -66,6 +68,9 @@ private Q_SLOTS:
void goToSymbol(const QString &locationId); void goToSymbol(const QString &locationId);
private: private:
// Record one activation of \a id and persist. Feeds the frecency bonus so
// habitual choices float up in every palette.
void recordUsage(const QString &id);
// One harvested, runnable action with the provenance needed to build a // One harvested, runnable action with the provenance needed to build a
// unique id and to disambiguate labels shared by several commands. // unique id and to disambiguate labels shared by several commands.
struct ActionEntry { struct ActionEntry {
@ -121,6 +126,7 @@ private:
bool m_swallowMiddleRelease = false; // swallow the release paired with a consumed middle press bool m_swallowMiddleRelease = false; // swallow the release paired with a consumed middle press
bool m_swallowRightRelease = false; // swallow the release paired with a left+right paste chord bool m_swallowRightRelease = false; // swallow the release paired with a left+right paste chord
QHash<QString, QPointer<QAction>> m_actionsById; QHash<QString, QPointer<QAction>> m_actionsById;
FrecencyStore m_frecency; // persisted usage feeding the palette ranking
}; };
} // namespace katecustom } // namespace katecustom