Update: I will no longer be working on Clanki and RWKV-mini. My Claude Code account randomly got banned and when I requested further investigation, I got this:
I could maaaaaybe work on RWKV, granted it would be very slow and more limited, but there is absolutely no way to maintain an entire Anki fork alone, on my own.
Ngl, I don’t feel great right now.
And I’m not rushing to try Codex or other alternatives because the country where I live is blocked by every LLM provider. For Claude Code I was using a burner phone number + a card with a billing address that has nothing to do with where I live (different country), which is probably why I got banned. Probably. They never specified what exactly I violated.
I’ll keep using my Clanki build, at least until FSRS-7 is integrated into Anki natively. Then I’ll probably switch to official Anki. Maybe someone will look at the history of my Clanki fork (aside from UI, it’s mostly performance optimizations) and use it as a starting point for Anki speedups, but the chances of that are very slim.
EDIT: some of my Clanki speedups are being ported to Anki:
main ← user1823:selection
opened 04:49PM - 04 Oct 26 UTC
Part B1 of https://github.com/Expertium/Clanki/pull/34
## Linked issue (requi… red)
## Summary / motivation (required)
After selecting many cards (e.g. Ctrl+A on ~50k cards) and running a new search, the browser freezes for many seconds.
`DataModel.get_item_rows()` is called from `Table._restore_selection()` to map the saved selection back to row numbers. It tested each table row with `i in items`, where `items` is a list. A list membership test is a linear scan, so the function was O(rows × selected). The work runs on the UI thread, so the whole window locks up.
**Fix**
Build a `set` of the saved items once, then test membership against it:
```python
wanted = set(items)
return [row for row, i in enumerate(self._items) if i in wanted]
```
Set lookups are O(1) on average, so the function is now O(rows + selected).
## Steps to reproduce (required, use N/A if not applicable)
N/A
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or behavior changed.
### Details
## Before / after behavior (optional)
No change
## Risk / compatibility / migration (optional)
## UI evidence (required for visual changes; otherwise N/A)
## Scope
- [x] This PR is focused on one change (no unrelated edits).
main ← browser-selection-perf
opened 10:34AM - 06 Oct 26 UTC
## Linked issue
This is a small follow-up to #5755 and #5756.
## Summary
…
I noticed browser selections are still very slow. It turned out QHeaderView was [scanning all row flags on every paint](https://github.com/qt/qtbase/blob/8ba7ea4b77a4b8f1948760221e264917ddc9e1c8/src/corelib/itemmodels/qitemselectionmodel.cpp#L1602). Setting an empty selection model for the header significantly improves performance; the browser can now select 300K rows in a second, while it used to take at least 30 seconds according to my tests.
## How to test
Here's a benchmark test generated by Claude. Save at `qt/tests/browser_selection_bench.py` and run using `out/pyenv/bin/python qt/tests/browser_selection_bench.py --notes 50000`:
[browser_selection_bench.py](https://github.com/user-attachments/files/33101079/browser_selection_bench.py)
Here's a sample run before/after:
```
## Before
created 50000 notes in 2.4s
browser: 50000 rows, 4 columns, Qt platform offscreen
click one row (from all) freeze= 5.2ms busy= 49.0ms done= 634.5ms selected= 1
Ctrl+A freeze= 1167.9ms busy= 1348.8ms done= 1689.9ms selected= 50000
Edit > Select All freeze= 2304.5ms busy= 6193.1ms done= 6244.5ms selected= 50000
Edit > Invert Selection freeze= 4969.9ms busy= 5056.2ms done= 5616.7ms selected= 49999
Shift+click last row freeze= 546.0ms busy= 1095.3ms done= 1530.8ms selected= 50000
Ctrl+click one row (from all) freeze= 1.2ms busy= 5.0ms done= 493.0ms selected= 49999
selected_cards() (all) freeze= 570.4ms busy= 573.5ms done= 597.5ms selected= 50000
selected_notes() (all) freeze= 629.8ms busy= 630.0ms done= 630.1ms selected= 50000
search again (all) freeze= 646.2ms busy= 668.0ms done= 891.1ms selected= 1
toggle cards/notes (all) freeze= 641.5ms busy= 667.7ms done= 1150.9ms selected= 1
toggle red flag (all) freeze= 980.4ms busy= 987.5ms done= 1226.6ms selected= 50000
resize first column (all) freeze= 523.1ms busy= 528.1ms done= 769.8ms selected= 50000
## After
created 50000 notes in 2.4s
browser: 50000 rows, 4 columns, Qt platform offscreen
click one row (from all) freeze= 6.5ms busy= 37.7ms done= 177.2ms selected= 1
Ctrl+A freeze= 11.3ms busy= 21.9ms done= 310.6ms selected= 50000
Edit > Select All freeze= 589.9ms busy= 600.7ms done= 670.7ms selected= 50000
Edit > Invert Selection freeze= 1151.1ms busy= 1163.8ms done= 1221.3ms selected= 49999
Shift+click last row freeze= 6.7ms busy= 30.4ms done= 187.1ms selected= 50000
Ctrl+click one row (from all) freeze= 1.3ms busy= 6.8ms done= 494.6ms selected= 49999
selected_cards() (all) freeze= 618.1ms busy= 618.4ms done= 978.1ms selected= 50000
selected_notes() (all) freeze= 631.7ms busy= 638.3ms done= 983.1ms selected= 50000
search again (all) freeze= 644.9ms busy= 666.3ms done= 983.3ms selected= 1
toggle cards/notes (all) freeze= 680.7ms busy= 720.3ms done= 914.4ms selected= 1
toggle red flag (all) freeze= 1036.1ms busy= 1042.0ms done= 1295.9ms selected= 50000
resize first column (all) freeze= 3.3ms busy= 6.7ms done= 125.9ms selected= 50000
```
I haven’t tried it yet but OpenCode seems to be a popular workaround. Since it’s open source, it can be used from any country, users can use the API of their preferred AI model, and local LLMs can also be used if they have a GPU.
I’m now using OpenHands (open source harness, kinda like Claude Code) and GPT-6 Luna (best in terms of price-performance) in Flex API mode (higher time-to-first-token aka latency, lower throughput tokenss, and worse server uptime, but 2x lower cost) at “medium” reasoning effort.
So I’m using a poverty LLM in poverty mode at poverty reasoning effort
I’ll probably end up spending 100-200$ per month, comparable to my Claude subscription, except with much lower level of intelligence (8-12 months behind the frontier). Well, better than nothing, I guess.
I’ll keep working on RWKV-mini and Clanki.
EDIT: OpenHands is…not great, to put it politely. I’ll look for other open-source alternatives. But yeah, the overall plan is financially workable, the question at this point is whether I can find an open-source harness that isn’t a buggy mess, and, of course, quality of code written by behind-the-frontier models.
EDIT 2: OpenCode straight up doesn’t work for me. Yeah, finding a good open-source harness is going to be a problem. My last couple of updates have been oscillating between “We’re so back” and “It’s so over”.
EDIT 3: guess I’m forking OpenHands
Re: RWKV development (not so sure about Clanki though):
I don’t know the nature of your work, but if you can make good progress via async “batch” style development, I can donate Astra / Astra Pro use to your efforts. You’d need to give me what needs to be run and I can provide the outputs. So obviously this doesn’t work for tight work loops but perhaps there’s something I can contribute that augments what you do with the “poverty” models meanwhile…
An easy way to do this for me would be via a GitHub pull request. You can comment with what you want done next, comment on lines, and upload any artifacts (as long as it’s not like arbitrary binaries), and I can update the PR with the result.
I have decided to stop working on RWKV and Clanki. OpenHands is a buggy mess and GPT-6 Luna is not intelligent enough. I’ll try again in 6-12 months, perhaps by then OpenHands will be less buggy and cheap models will catch up to frontier models of today.
In the meantime JSchoreels (Jonathan Schoreels) · GitHub will continue my work on RWKV and will integrate it into his own fork of Anki.
EDIT:
There’s a lot of perf improvement PRs based on my Clanki:
main ← user1823:sidebar
opened 05:45PM - 04 Oct 26 UTC
Most significant changes from https://github.com/Expertium/Clanki/pull/97
## … Linked issue (required)
## Summary / motivation (required)
Two small, behavior-preserving changes to cut per-keystroke overhead in the Browser sidebar's filter box and the redraw after a refresh:
1. `SidebarItemType.section_roots()` / `is_section_root()` filtered all enum members and ran `str.endswith` on each name on every call. `show_expanded()` calls `is_section_root()` for every item on every keystroke. The roots are now computed once at import time (a tuple, plus a frozenset for membership).
2. `SidebarTreeView._expand_where_necessary()` walked the tree through the Qt model API (`rowCount()` + `index()` per item, and `index()` itself calls `hasIndex()`, which calls `rowCount()`/`columnCount()` again). It now iterates `item.children` and uses the existing `model.index_for_item()`, which builds the index from the cached `_row_in_parent`. Traversal order (children first, then the item), expansion, current-item selection and scrolling are unchanged.
## Steps to reproduce (required, use N/A if not applicable)
N/A
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or behavior changed.
### Details
## Before / after behavior (optional)
No change
## Risk / compatibility / migration (optional)
## UI evidence (required for visual changes; otherwise N/A)
## Scope
- [x] This PR is focused on one change (no unrelated edits).
main ← user1823:repaint
opened 09:53AM - 06 Oct 26 UTC
Borrowed from https://github.com/Expertium/Clanki/commit/f29cb6bfd9e3bb105c5e529… e66ad02f66b08c59f
## Linked issue (required)
## Summary / motivation (required)
A full repaint of the browser table (after a focus change, an operation, or a search) calls `DataModel.data()` about nine times per visible cell, and `StatusDelegate.paint()` once per cell. For 41 rows × 13 columns that is roughly 4,800 `data()` calls. A significant portion of the cost of each call is Python overhead rather than real work:
- Each call looks up `Qt.ItemDataRole.*` enum attributes.
- The alignment branch builds `AlignVCenter | AlignHCenter` with an enum `|` and then calls `getattr(align, "value", align)`.
- `flags()` builds `ItemIsEnabled | ItemIsSelectable` on every call.
- `StatusDelegate.paint()` calls `get_cell(index)` twice.
### Changes
- `qt/aqt/browser/table/model.py`
- Add module-level constants for the roles, the alignment values and the item flags.
- `data()` now compares `role` against plain ints and returns the precomputed alignment ints.
- `DisplayRole`, the most frequent role, is checked first.
- `flags()` returns the precomputed flag objects.
- `qt/aqt/browser/table/table.py`
- `StatusDelegate.paint()` calls `get_cell(index)` once and reuses the result.
## Steps to reproduce (required, use N/A if not applicable)
N/A
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or behavior changed.
### Details
## Before / after behavior (optional)
No change
## Risk / compatibility / migration (optional)
## UI evidence (required for visual changes; otherwise N/A)
## Scope
- [x] This PR is focused on one change (no unrelated edits).
main ← user1823:Cloop
opened 10:33AM - 06 Oct 26 UTC
The most significant changes from https://github.com/Expertium/Clanki/commit/265… 47699bfefa538c66a2069c46068786796588e
## Linked issue (required)
## Summary / motivation (required)
After a search, the browser restores the selection with `get_item_rows()` and `get_item_row()`. The search result (`_items`) is a protobuf repeated container, so a Python-level loop over it is slow. `count_enabled_cells()` (Ctrl/Shift selection) has the same pattern, with a Python generator over the rows of each selected range.
## Changes
- `get_item_row`: `next(compress(count(), map(eq, self._items, repeat(item))), None)`
- `get_item_rows`: `list(compress(count(), map(wanted.__contains__, self._items)))`
- `count_enabled_cells`: slice the range and count disabled rows with `sum(map(disabled.__contains__, ...))`, instead of a generator over every row.
- The local `count` is renamed `cells` so it doesn't shadow `itertools.count`.
## Steps to reproduce (required, use N/A if not applicable)
N/A
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or behavior changed.
### Details
## Before / after behavior (optional)
No change
## Risk / compatibility / migration (optional)
## UI evidence (required for visual changes; otherwise N/A)
## Scope
- [x] This PR is focused on one change (no unrelated edits).
main ← user1823:dql
opened 11:57AM - 11 Oct 26 UTC
Borrowed from https://github.com/Expertium/Clanki/pull/299
## Linked issue (req… uired)
## Summary / motivation (required)
**Problem**
`dbproxy.rs` treats any statement that doesn't start with `select` as a modification. After a "modification" it calls `update_state_after_dbproxy_modification()`, which discards the undo step and study queues and marks the collection modified.
So read-only queries that begin with something else, such as `WITH ... SELECT`, `PRAGMA table_info(...)` or `VALUES (...)`, silently wipe undo and the queues. A minimal reproducer from an add-on or script:
```python
mw.col.add_deck(...) # undoable op; Edit > Undo is available
mw.col.db.all("with t as (select 1 x) select x from t")
# Undo is now gone
```
**Fix**
Replace the text check (`is_dql`) with SQLite's own answer for the prepared statement. A statement counts as a read if:
- `Statement::readonly()` (`sqlite3_stmt_readonly`) is true, **and**
- `column_count() > 0` (it has a result set).
The second condition keeps `SAVEPOINT`, `BEGIN`, `COMMIT`, `ATTACH` and `DETACH` classed as modifications. SQLite reports them as read-only, but they aren't reads. `INSERT ... RETURNING` and `WITH ... DELETE/UPDATE/INSERT` stay writes because they aren't read-only.
The check uses `prepare_cached`, so the query that runs next gets the same statement from the cache and there is no second prepare.
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [x] I added or updated tests when the change is non-trivial or behavior changed.
### Details
## Before / after behavior (optional)
| Statement | Before | After |
| --- | --- | --- |
| `SELECT ...` | read | read |
| `WITH ... SELECT` | write | read |
| `PRAGMA table_info(...)`, `VALUES (...)` | write | read |
| `WITH ... DELETE/UPDATE/INSERT` | write | write |
| `INSERT ... RETURNING` | write | write |
| `SAVEPOINT`, `BEGIN`, `COMMIT`, ... | write | write |
| SQL that fails to prepare | state dropped, then error | error, state untouched |
## Risk / compatibility / migration (optional)
## UI evidence (required for visual changes; otherwise N/A)
## Scope
- [x] This PR is focused on one change (no unrelated edits).
main ← user1823:batch_cdb
opened 12:46PM - 11 Oct 26 UTC
Borrowed from https://github.com/Expertium/Clanki/pull/308
## Linked issue (r… equired)
## Summary / motivation (required)
Check Database and Empty Cards walk every note and run one point query per note to read that note's cards (and, for Empty Cards, the note itself). On large collections, those queries account for a significant fraction of time spent in the Rust code. Card generation also recomputes the notetype's special-field set for every note.
## Changes
1. **Check Database** reads a notetype's cards with one query (`existing_cards_for_notetype`, which already exists) and groups them by note, sorted by card id. This is the order `existing_cards_for_note` returns them in, and the order the "keep the oldest duplicate" logic relies on. If the duplicate/missing-template checks removed a card for a note, we re-query that one note, so card generation still sees the remaining cards. Card generation now goes through a new generate_cards_for_existing_note_with_cards`, which takes the cards instead of querying for them.
2. **Card generation** computes the set of special fields that always count as nonempty once per `CardGenContext`, rather than once per note. A note field named `Tags` still takes precedence over the note's tags.
3. **Empty Cards** reads notes 1000 at a time with a new `get_notes_by_id` instead of one query per note.
## Why this is safe
- In Check Database, writing a note only changes that note and its own cards, so the cards read up front are the ones the per-note query would have returned.
- Notes are still processed in the same order, and the writes happen in the same order as before.
## Steps to reproduce (required, use N/A if not applicable)
N/A
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [x] I added or updated tests when the change is non-trivial or behavior changed.
### Details
## Before / after behavior (optional)
## Risk / compatibility / migration (optional)
No change
## UI evidence (required for visual changes; otherwise N/A)
## Scope
- [x] This PR is focused on one change (no unrelated edits).
main ← user1823:mem-state
opened 06:48AM - 18 Oct 26 UTC
Borrowed from https://github.com/Expertium/Clanki/pull/374
## Linked issue (req… uired)
## Summary / motivation (required)
After changing the desired retention or FSRS parameters and saving Deck Options, and after using Change Deck, `update_memory_state_for_cards_with_items` recomputes every card's memory state. It processed the 1,000-card batches one after another and read each card with its own get_card query.
This PR:
- Computes the batches in parallel with rayon. Each batch is computed exactly as before, and the results are collected in order.
- Reads each batch's cards with one all_cards_for_ids call instead of one query per card.
- Keeps the card writes sequential and in the same order. Rescheduling, the load balancer and undo therefore see the same sequence as before.
## Steps to reproduce (required, use N/A if not applicable)
N/A
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or behavior changed.
### Details
## Before / after behavior (optional)
No change
## Risk / compatibility / migration (optional)
## UI evidence (required for visual changes; otherwise N/A)
## Scope
- [x] This PR is focused on one change (no unrelated edits).
Though they are relatively minor. The big ones were
main ← user1823:selection
opened 04:49PM - 04 Oct 26 UTC
Part B1 of https://github.com/Expertium/Clanki/pull/34
## Linked issue (requi… red)
## Summary / motivation (required)
After selecting many cards (e.g. Ctrl+A on ~50k cards) and running a new search, the browser freezes for many seconds.
`DataModel.get_item_rows()` is called from `Table._restore_selection()` to map the saved selection back to row numbers. It tested each table row with `i in items`, where `items` is a list. A list membership test is a linear scan, so the function was O(rows × selected). The work runs on the UI thread, so the whole window locks up.
**Fix**
Build a `set` of the saved items once, then test membership against it:
```python
wanted = set(items)
return [row for row, i in enumerate(self._items) if i in wanted]
```
Set lookups are O(1) on average, so the function is now O(rows + selected).
## Steps to reproduce (required, use N/A if not applicable)
N/A
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or behavior changed.
### Details
## Before / after behavior (optional)
No change
## Risk / compatibility / migration (optional)
## UI evidence (required for visual changes; otherwise N/A)
## Scope
- [x] This PR is focused on one change (no unrelated edits).
and
main ← user1823:row-counting
opened 05:10PM - 04 Oct 26 UTC
Part B2 of https://github.com/Expertium/Clanki/pull/34
## Linked issue (requi… red)
## Summary / motivation (required)
In `Table._on_selection_changed`, when Shift or Ctrl is held, the number of added/removed rows is computed with `len(selected.indexes())` and `len(deselected.indexes())`. Qt's `QItemSelection.indexes()` calls the Python `DataModel.flags()` once per cell. For Ctrl+A on 50k cards with 8 columns, that's ~400k Python calls on the UI thread, which freezes the browser for seconds.
**Fix**
Add `DataModel.count_enabled_cells(selection)`, which returns the same number as `len(selection.indexes())` without calling `flags()`:
- For each selection range, the count is `(enabled rows in range) * range.width()`.
- A row is excluded if it's cached and `is_disabled`, which is exactly the condition under which `flags()` returns no flags.
- Overlapping ranges are counted repeatedly, as `indexes()` does.
`_on_selection_changed` uses it in place of `len(...indexes())`; the division by `len_columns()` is unchanged.
## Steps to reproduce (required, use N/A if not applicable)
N/A
## How to test (required)
### Checklist (minimum)
- [ ] I ran `./ninja check` or an equivalent relevant check locally.
- [ ] I added or updated tests when the change is non-trivial or behavior changed.
### Details
## Before / after behavior (optional)
No change.
## Risk / compatibility / migration (optional)
## UI evidence (required for visual changes; otherwise N/A)
## Scope
- [x] This PR is focused on one change (no unrelated edits).
This is a draft only because of
<img width="900" src="https://github.com/user-attachments/assets/749efa47-3c55-41ba-8881-dce97c9658cf" />
EDIT2: ok, I found a decent open-source harness (Goose), but GPT-6 Luna isn’t great, so progress will be slow