From 7195a6c11b192f2f6dd75162937e196c13aa5194 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Wed, 7 Oct 2026 21:00:00 +0200 Subject: [PATCH] Frecency: usage-aware ranking across all palettes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/PLAN.md | 21 ++++- src/palette/CMakeLists.txt | 6 ++ src/palette/frecencystore.cpp | 135 +++++++++++++++++++++++++++++ src/palette/frecencystore.h | 72 +++++++++++++++ src/palette/test_frecencystore.cpp | 115 ++++++++++++++++++++++++ src/plugin/ollieplugin.cpp | 52 ++++++++++- src/plugin/ollieplugin.h | 6 ++ 7 files changed, 402 insertions(+), 5 deletions(-) create mode 100644 src/palette/frecencystore.cpp create mode 100644 src/palette/frecencystore.h create mode 100644 src/palette/test_frecencystore.cpp diff --git a/docs/PLAN.md b/docs/PLAN.md index 756a97a..ce5ffe4 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -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 diff --git a/src/palette/CMakeLists.txt b/src/palette/CMakeLists.txt index 708f174..0b30487 100644 --- a/src/palette/CMakeLists.txt +++ b/src/palette/CMakeLists.txt @@ -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() diff --git a/src/palette/frecencystore.cpp b/src/palette/frecencystore.cpp new file mode 100644 index 0000000..3763fd5 --- /dev/null +++ b/src/palette/frecencystore.cpp @@ -0,0 +1,135 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + */ +#include "frecencystore.h" + +#include +#include +#include +#include +#include +#include +#include + +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(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(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 diff --git a/src/palette/frecencystore.h b/src/palette/frecencystore.h new file mode 100644 index 0000000..4ea6d12 --- /dev/null +++ b/src/palette/frecencystore.h @@ -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 +#include + +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 m_entries; +}; + +} // namespace katecustom + +#endif diff --git a/src/palette/test_frecencystore.cpp b/src/palette/test_frecencystore.cpp new file mode 100644 index 0000000..99a35b7 --- /dev/null +++ b/src/palette/test_frecencystore.cpp @@ -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 +#include +#include + +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" diff --git a/src/plugin/ollieplugin.cpp b/src/plugin/ollieplugin.cpp index 5346cee..10d0f08 100644 --- a/src/plugin/ollieplugin.cpp +++ b/src/plugin/ollieplugin.cpp @@ -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 #include +#include #include #include #include @@ -32,6 +34,7 @@ #include #include #include +#include #include #include @@ -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 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 symbols = SymbolIndex::listSymbols(m_symbolSwitcherRoot, files); + const qint64 now = QDateTime::currentSecsSinceEpoch(); QList 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); } diff --git a/src/plugin/ollieplugin.h b/src/plugin/ollieplugin.h index b6df1ec..a91e107 100644 --- a/src/plugin/ollieplugin.h +++ b/src/plugin/ollieplugin.h @@ -13,6 +13,8 @@ #include +#include "frecencystore.h" + #include #include #include @@ -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> m_actionsById; + FrecencyStore m_frecency; // persisted usage feeding the palette ranking }; } // namespace katecustom