l3kn / org-fc

Spaced Repetition System for Emacs org-mode
https://www.leonrische.me/fc/index.html
GNU General Public License v3.0
258 stars 31 forks source link

[Feature request] Visualize future review distribution #100

Open cashpw opened 1 year ago

cashpw commented 1 year ago

I'm thinking of something like the graph generated by the Anki Simulator add-on.

l3kn commented 1 year ago

This seems very difficult to do right and I'm not sure how useful an inaccurate variant would be.

The simulation loop would need to compute the probabilities of each of the four ratings for each card. If we had such an algorithm, we could easily use it to predict the recall probability (like ebisu) for each card and use it to schedule reviews.

A simpler version could use the same probabilities for each card, computed from the past reviews. Below is some code that does this with some hard-coded numbers.

At the moment I'm not diligent enough to review all due cards each day (or even multiple times each day) to judge how my real review counts differ from the predicted ones.

(require 'time-date)

(defun off-flatten (card)
  (unless (plist-get card :suspended)
    (mapcar
     (lambda (pos)
       (list
        :due (plist-get pos :due)
        :ease (plist-get pos :ease)
        :box (plist-get pos :box)
        :interval (plist-get pos :interval)))
     (plist-get card :positions))))

(defun off-insert (pos buckets)
  (let* ((due (plist-get pos :due))
         (bucket (-find (lambda (b) (time-less-p due (plist-get b :end))) buckets)))
    (when bucket
      (push pos (plist-get bucket :positions)))))

(defun off-rate-pos (pos rating)
  (cl-destructuring-bind (ease box interval)
      (org-fc-algo-sm2-next-parameters
       (plist-get pos :ease)
       (plist-get pos :box)
       (plist-get pos :interval)
       rating)
    (plist-put pos :ease ease)
    (plist-put pos :box box)
    (plist-put pos :interval interval)
    (plist-put pos :due
               (encode-time
                (decoded-time-add
                 (decode-time
                  (plist-get pos :due))
                 (make-decoded-time :second
                                    (*
                                     interval
                                     60 60 24)))))))

(defun off-rating ()
  (let ((r (cl-random 1.0)))
    (cond
     ((< r 0.10) 'again)
     ((< r 0.15) 'hard)
     ((< r 0.60) 'good)
     (t 'easy))))

(defun off-step (buckets)
  (let ((first-non-empty
         (-find (lambda (b) (not (null (plist-get b :positions))))
                buckets)))
    (when first-non-empty
      (let* ((poss (plist-get first-non-empty :positions))
             (poss_
              (mapcar (lambda (pos)
                        (off-rate-pos pos (off-rating))) poss)))
        (cl-incf (plist-get first-non-empty :reviews)
                 (length poss))
        (plist-put first-non-empty :positions '())
        (dolist (pos poss_)
          (off-insert pos buckets)))
      buckets)))

(defun off-iterate (buckets)
  (while (off-step buckets))
  buckets)

(defun off-forecast (context)
  (interactive (list (org-fc-select-context)))
  (let* ((now (encode-time (decode-time)))
         (poss
          ;; To simplify the code, we'll assume cards are reviewed on
          ;; exactly when they become due. For cards that are already due,
          ;; we'll rewrite the due date to the current time.
          ;; (mapcan #'off-flatten (org-fc-index context))
          (mapcar
           (lambda (pos)
             (if (time-less-p (plist-get pos :due) now)
                 (plist-put pos :due now)
               pos))
           (mapcan #'off-flatten (org-fc-index context))))
         (buckets (off-buckets 90 0)))
    (dolist (pos poss) (off-insert pos buckets))
    (off-iterate buckets)))

(defun off-show (context)
  (interactive (list (org-fc-select-context)))
  (with-current-buffer (get-buffer-create "*org-fc Forecast*")
    (let* ((org-fc-algorithm 'sm2-v2)
           (buckets (off-forecast context)))

      (goto-char (point-min))
      (erase-buffer)
      (dolist (buk buckets)
        (insert (format "%s - %s\n"
                        (plist-get buk :end)
                        (plist-get buk :reviews))))

      (insert
       (format "Total reviews: %s\n"
               (cl-loop for buk in buckets summing
                        (plist-get buk :reviews))))
      (switch-to-buffer (current-buffer)))))

(defun off-buckets (n &optional new-per-day)
  (setq new-per-day (or new-per-day 0))
  (let* ((cur (decode-time (current-time)))
         (day-end
          (make-decoded-time
           :second 59
           :minute 59
           :hour 23
           :day (nth 3 cur)
           :month (nth 4 cur)
           :year (nth 5 cur))))
    (cl-loop
     for d from 0 to (1- n) collecting
     (let ((time
            (encode-time
             (decoded-time-add day-end (make-decoded-time :day d))))
           (start
            (encode-time
             (decoded-time-add day-end (make-decoded-time :day (1- d))))))
       (list :end time
             :reviews 0
             :positions
             (cl-loop for i from 0 below new-per-day collecting
                      (list
                       :due start
                       :ease 2.5
                       :box 0
                       :interval 0)))))))