Spaced repetition in Emacs org-mode

This post shares my experience with a simple spaced repetition utility in Emacs org-mode.

What’s this about?

Spaced repetition (SR) is a type of learning technique which is predicated on a typical, observed memory degradation rate and schedules one’s reviewing intervals accordingly, so as to maximize the knowledge acquisition while minimizing the efforts.

A schematic memory retention curve may look like this:

Memory retention curve. Obtained from https://memordo.com/blog/spaced-repetition

At the designed reviewing time points (vertical lines in Figure), one carries out a review of the material to be memorized, and sometimes also gives a performance rating. The next review will be scheduled further into the future. Some systems also adjust the intervals dynamically depending on how well the recall performance was, saving efforts for those more difficult learning tasks.

This post shares a custom spaced repetition implementation inside Emacs’s org-mode.

Motivation

There are a number of softwares built upon the idea of spaced repetition. E.g. Anki seems to be a rather popular choice. Inside Emacs, there is a org-drill third party package that allows one to create flashcards from org-mode notes, and offers a few different algorithms to compute the "optimal" review schedule, incorporating your recall performance feedback.

One big issue I found about these implementations is: they all seem to be built around second language learning, in particular, vocabulary acquisition. It doesn’t have to be restricted to vocabulary, of course. But the process of composing all those flashcards from individual pieces of information/facts can be a rather daunting task. Imagine building a flashcard set about, for instance, classes in the C++ language, or the quasi-geostrophic theory in fluid dynamics. There will be a whole array of information, often so tightly inter-linked, that each in its own isolation doesn’t make too much sense.

It is perhaps more appropriate to treat such contents as a whole entity, and ditch the flashcard idea, but retrain the spaced repetition mechanics.

Besides, it would be nice to have the SR tool integrated with Emacs, my everyday note-taking application.

I have been looking for such a system for some time, until I came across a post in org-roam’s forum, where Andrea (due credits to her) shared her own Elisp implementation that integrates a simple spaced repetition workflow flawlessly with org-mode.

I had a quick look at the code, and immediately decided to gave it a try. We had some discussions on some aspects of it, and I made some small changes to the Elisp code to better suit my needs. I’ve been using it for about one and half months. And I’d like to share my experiences in this post.

How does it work?

This SR utility is built around org-agenda, an integral part of org-mode. (Very powerful and useful, highly recommended.)

Example. A TODO item in org-mode may look like this:

A normal TODO item in org-mode.

Suppose I’ve finished my 1st-time learning about this particular topic, and I’d like to include this into my review schedule. I’d run on this heading the custom Elisp function my-set-spaced-repetition (shown later), which is bound to the hot-key C-c r (detailed later). This function does the following:

  1. append the :SR: tag onto the heading, if it is not already there,
  2. increment the repetition counter by 1, starting from 0. This counter is shown as another tag, following :SR:. E.g. repetition0.
  3. set the review schedule to the next date by picking an interval from a prescribed list, in this case it would be the following day (++1d, shown later), since it is the 1st review.

The resultant heading looks like this:

A normal TODO item turned into an SR task.

I would then review the relevant material, whether it is included under the heading itself, or stored somewhere else, on the scheduled date (2022-05-04).

After that, I would flag this task as DONE, by running on the heading the org-todo command (bound to hot-key C-c C-t by default in org-mode). This triggers the my/space-repeat-if-tag-spaced function (shown later) that I modified from Andrea’s code, and it does the following things:

  1. increment the repetition counter by 1, in this case, to repetition1.
  2. schedule the next review date by picking the interval corresponding to this incremented level of repetition. In this case, 2 days later (++2d).
  3. modify the tags of the heading to reflect these changes.
  4. revert the TODO statue to its previous value, so DONE -> TODO.

The resultant heading now looks like this:

The SR task scheduled to repetition-1.

The similar process then repeats, according to this repetition interval table:

repetition time interval (days or months)
0 1 d
1 2 d
2 7 d
3 15 d
4 30 d
5 60 d
6 4 m

Because these headings are decorated as TODO items, they will be included in the agenda-view. And, because they all have the dedicated :SR: tag, I could filter out such review tasks from all other TODOs, using the built-in Match a TAGS/PROP/TODO query function of agenda-view. The filtered list may look like this:

Filtered spaced repetition tasks in agenda-view.

The Elisp code

Below is the my/space-repeat-if-tag-spaced function, the core part of this utility:

(defun my/space-repeat-if-tag-spaced (e)
  "Resets the header on the TODO states and increases the date
according to a suggested spaced repetition interval."
  (let* ((spaced-rep-map '((0 . "++1d")
                           (1 . "++2d")
                           (2 . "++7d")
                           (3 . "++15d")
                           (4 . "++30d")
                           (5 . "++60d")
                           (6 . "++4m")))
         (spaced-key "SR")
         (tags (org-get-tags nil t))
         (spaced-todo-p (member spaced-key tags))
         (repetition-n (car (cdr spaced-todo-p)))
         (n+1 (if repetition-n (+ 1 (string-to-number (substring repetition-n (- (length repetition-n) 1) (length repetition-n)))) 0))
         (spaced-repetition-p (alist-get n+1 spaced-rep-map))
         (new-repetition-tag (concat "repetition" (number-to-string n+1)))
         (new-tags (reverse (if repetition-n
                                (seq-reduce
                                 (lambda (a x) (if (string-equal x repetition-n) (cons new-repetition-tag a) (cons x a)))
                                 tags
                                 '())
                              (seq-reduce
                               (lambda (a x) (if (string-equal x spaced-key) (cons new-repetition-tag (cons x a)) (cons x a)))
                               tags
                               '())))))
    (if (and spaced-todo-p spaced-repetition-p)
        (progn
          ;; avoid infinitive looping
          (remove-hook 'org-trigger-hook 'my/space-repeat-if-tag-spaced)
          ;; reset to previous state
          (org-call-with-arg 'org-todo 'previousset)
          ;; schedule to next spaced repetition
          (org-schedule nil ".")
          (org-schedule nil (alist-get n+1 spaced-rep-map))
          ;; rewrite local tags
          (org-set-tags new-tags)
          (add-hook 'org-trigger-hook 'my/space-repeat-if-tag-spaced))
      )))

(add-hook 'org-trigger-hook 'my/space-repeat-if-tag-spaced)

The repetition interval table is stored in this list:

((0 . "++1d")
(1 . "++2d")
(2 . "++7d")
(3 . "++15d")
(4 . "++30d")
(5 . "++60d")
(6 . "++4m"))

You can adjust the intervals to your liking.

Below is the my-set-spaced-repetition function I created, to facilitate converting a normal org-mode heading into an SR task. The last line defines the hot-key binding (bound to C-c r).

(defun my-set-spaced-repetition ()
  "When called on a heading: 1) append the :SR: tag if not already there, 2) set a schedule
   to the next day (++1d)"
    (interactive)
    (if (org-current-level) (progn 
	(let* ((tags (org-get-tags nil t))
	       (sr-tag "SR"))
	(if (member sr-tag tags) (message "Already has SR tag.")
	    (progn (add-to-list 'tags sr-tag t) ; append SR to tag list
                 (add-to-list 'tags "repetition0" t)
		   (org-set-tags tags)
		   (message "Added SR tag to heading.")
	    ))
	(org-schedule nil "++1d")
))))

(global-set-key (kbd "C-c r") 'my-set-spaced-repetition)

Some remarks

I’ve been using this rudimentary spaced repetition tool on a semi-daily basis for about one and a half months, and I’m loving it.

I mostly use it to keep track of my learning progress, covering topics like C++ language tutorials, computing algorithms, or books I am reading. These are all rather different use cases from the conventional vocabulary flashcard type of spaced repetition in that they are all rather "chunky" in size.

Sometimes, I don’t actually put any contents under the SR headings. They therefore serve only as a reminder that I should review these materials on the "correct" date.

This saves me a lot of time from creating a big pile of flashcards, a task you typically have to do for a vocabulary acquisition SR system, but is less obvious how to properly carry out (or whether it is even meaningful), for complicated contents like the formulation of a Kalman filter.

One down-side of my adopted approach is that the tasks are often too big, and it is very difficult to accurately assess the recall performance. This is simply because when a heading covers multiple sub-sections, some parts of it may be more easily memorized than others, but they are all reviewed according to the same schedule.

Another, more direct drawback of big headings is that they take a longer time to review. Some of my headings require more than half an hour to go through, even when I can already recall most of the contents rather well. And things only get worse when multiple review threads collide onto the same date. Sometimes I would find myself sitting in front of my computer late at night, with multiple tasks to review but simply not enough of time before the clock turns mid-night.

To counter this issue, I made a small change to Andrea’s version of the my/space-repeat-if-tag-spaced function: I changed the heading tag querying function call from (org-get-tags) to (org-get-tags nil t). The last t flag specifies that the function does not include inherited tags (in org-mode, heading tags can be inherited from upper-level headings, or from a global list applicable to the entire org file).

This allows me to turn sub-headings into their SR tasks, possibly having a different thread of review schedule, thus giving me more granular control over the reviewing frequency.

But this small technical change won’t magically solve the entire problem for me. I will have to be more specific about what I’d like to memorize, rather than simply putting book chapter numbers as a SR task. However, I’ve noticed that once I have gone through a couple of repetitions, the important contents in those book chapters would start to emerge themselves. I could take those parts out and turn them into SR tasks. This is both a process of knowledge distillation and rephrasing, and fosters more accurate recall monitoring.

Leave a Reply