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
the group column. Accepting a row triggers the underlying `QAction`.
- Installs to `kf6/ktexteditor`; metadata verified valid via `KPluginMetaData`.
- Not yet wired: frecency (the model supports a bonus, but the plugin currently
passes 0 for every action — usage tracking is deferred).
- Frecency: wired. `OllieView` holds a persisted `FrecencyStore`; activations
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
exact same action object reachable through several GUI clients is listed
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`)
and reachable from a radial by objectName `ollie_goto_symbol`.
(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

View File

@ -3,6 +3,8 @@ add_library(palette STATIC
palettemodel.h
palettewidget.cpp
palettewidget.h
frecencystore.cpp
frecencystore.h
)
target_link_libraries(palette PUBLIC fuzzyranker Qt6::Core Qt6::Gui Qt6::Widgets)
target_include_directories(palette PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
@ -11,4 +13,8 @@ if(Qt6Test_FOUND)
add_executable(test_palettemodel test_palettemodel.cpp)
target_link_libraries(test_palettemodel PRIVATE palette Qt6::Test Qt6::Widgets)
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()

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 "palettewidget.h"
#include "frecencystore.h"
#include "radialmenu.h"
#include "radialconfig.h"
#include "olliecommands.h"
@ -24,6 +25,7 @@
#include <QAction>
#include <QApplication>
#include <QDateTime>
#include <QDir>
#include <QEvent>
#include <QFileInfo>
@ -32,6 +34,7 @@
#include <QKeySequence>
#include <QMouseEvent>
#include <QSet>
#include <QStandardPaths>
#include <QUrl>
#include <QWidget>
@ -215,6 +218,14 @@ OllieView::OllieView(OlliePlugin *plugin, KTextEditor::MainWindow *mainWindow)
: QObject(plugin)
, 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
// keys sit together on a compact layout.
auto *openAction = new QAction(i18n("Command Palette (M-x)"), this);
@ -440,6 +451,12 @@ void OllieView::showCommandPalette()
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);
// Center the palette near the top of the active window.
@ -453,8 +470,21 @@ void OllieView::showCommandPalette()
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)
{
// 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.
// We call OllieCommands::runVerb rather than Editor::queryCommand because
// our Command's auto-registration is not reliably visible to queryCommand.
@ -492,13 +522,19 @@ void OllieView::showFileSwitcher()
connect(m_filePalette, &PaletteWidget::activatedId, this,
[this](const QString &id) {
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);
const QStringList files = ProjectIndex::listFiles(m_fileSwitcherRoot);
const qint64 now = QDateTime::currentSecsSinceEpoch();
QList<PaletteItem> items;
items.reserve(files.size());
@ -511,6 +547,8 @@ void OllieView::showFileSwitcher()
// the whole path. The base name still ranks well because an exact or
// substring hit on it outscores a scattered subsequence.
item.label = rel;
item.frecency = m_frecency.bonus(
kFilePrefix + m_fileSwitcherRoot + QLatin1Char('\t') + rel, now);
items.push_back(item);
}
m_filePalette->setItems(items);
@ -544,7 +582,11 @@ void OllieView::showSymbolSwitcher()
connect(m_symbolPalette, &PaletteWidget::activatedId, this,
[this](const QString &id) {
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);
const QStringList files = ProjectIndex::listFiles(m_symbolSwitcherRoot);
const QList<Symbol> symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files);
const qint64 now = QDateTime::currentSecsSinceEpoch();
QList<PaletteItem> items;
items.reserve(symbols.size());
@ -559,7 +602,8 @@ void OllieView::showSymbolSwitcher()
PaletteItem item;
// The id encodes the jump target as "relpath\tline"; the location is
// 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.
// The delegate renders only the label and FuzzyRanker scores against it,
@ -578,6 +622,8 @@ void OllieView::showSymbolSwitcher()
label += QStringLiteral(" — ") + suffix;
}
item.label = label;
item.frecency = m_frecency.bonus(
kSymbolPrefix + m_symbolSwitcherRoot + QLatin1Char('\t') + loc, now);
items.push_back(item);
}

View File

@ -13,6 +13,8 @@
#include <KTextEditor/Plugin>
#include "frecencystore.h"
#include <QObject>
#include <QPointer>
#include <QHash>
@ -66,6 +68,9 @@ private Q_SLOTS:
void goToSymbol(const QString &locationId);
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
// unique id and to disambiguate labels shared by several commands.
struct ActionEntry {
@ -121,6 +126,7 @@ private:
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
QHash<QString, QPointer<QAction>> m_actionsById;
FrecencyStore m_frecency; // persisted usage feeding the palette ranking
};
} // namespace katecustom