AXLR / Catalog / education

srs_schedule

Spaced-repetition scheduling with SuperMemo 2: from a review graded 0 to 5 and the card's state (repetitions, easiness factor, last interval), the next interval in days, the new easiness and the next review date. Or replay a card's whole review history. Exact easiness arithmetic.

Install

/tools install srs_schedule
/tools install srs_schedule@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · srs_schedule 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool srs_schedule; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
qualityintegerno5 perfect, 4 after hesitation, 3 with difficulty, 0-2 forgotten. ≥ 0; ≤ 5
repetitionsintegerno0≥ 0
easinessnumber | stringno2.5At least 1.3
intervalnumber | stringno0Days of the last interval
review_datestringnopattern ^\d{4}-\d{2}-\d{2}$
reviewsarray of objectnoA whole history instead of one review
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "quality": {
      "type": "integer",
      "minimum": 0,
      "maximum": 5,
      "description": "5 perfect, 4 after hesitation, 3 with difficulty, 0-2 forgotten"
    },
    "repetitions": {
      "type": "integer",
      "minimum": 0,
      "default": 0
    },
    "easiness": {
      "type": [
        "number",
        "string"
      ],
      "default": 2.5,
      "description": "At least 1.3"
    },
    "interval": {
      "type": [
        "number",
        "string"
      ],
      "default": 0,
      "description": "Days of the last interval"
    },
    "review_date": {
      "type": "string",
      "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
    },
    "reviews": {
      "type": "array",
      "items": {
        "type": "object",
        "required": [
          "date",
          "quality"
        ],
        "additionalProperties": false,
        "properties": {
          "date": {
            "type": "string"
          },
          "quality": {
            "type": "integer",
            "minimum": 0,
            "maximum": 5
          }
        }
      },
      "description": "A whole history instead of one review"
    }
  }
}

Example

Input

{
  "quality": 3,
  "repetitions": 2,
  "easiness": 2.5,
  "interval": 6,
  "review_date": "2026-10-18"
}

Output (stdout)

{
  "repetitions": 3,
  "easiness": 2.36,
  "interval_days": 15,
  "next_review": "2026-11-02"
}

Files

The command the install card registers is python3 srs_schedule.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

srs_schedule.py · sha256 968f19f7e6e975c6820948f5fe0fd79bd4da2540e8059131fd04c7ce29fbc43d
#!/usr/bin/env python3
"""SuperMemo 2 spaced repetition: the next interval, repetition count and easiness factor of
one card after a review graded 0-5, or a whole review history replayed from the start.

Input (JSON on stdin), either one review:
  quality       0-5 (5 perfect, 3 correct with difficulty, below 3 a lapse)
  repetitions   successful reviews in a row so far (default 0)
  easiness      easiness factor so far (default 2.5)
  interval      days of the last interval (default 0)
  review_date   "YYYY-MM-DD" (optional; gives next_review)
or a history:
  reviews       [{"date": "YYYY-MM-DD", "quality": 4}, ...] in order
"""
import datetime as dt
import json
import math
import sys
from fractions import Fraction


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def step(quality, repetitions, easiness, interval):
    """One SM-2 review. EF' = EF + (0.1 - (5 - q)(0.08 + (5 - q) 0.02)), floored at 1.3; a grade
    below 3 restarts the repetitions without changing EF; I(1) = 1, I(2) = 6, I(n) = I(n-1) EF',
    rounded up to whole days."""
    if quality < 3:
        return 0, easiness, 1
    q = 5 - quality
    easiness = max(Fraction(13, 10), easiness + Fraction(1, 10) - q * (Fraction(8, 100) + q * Fraction(2, 100)))
    repetitions += 1
    if repetitions == 1:
        interval = 1
    elif repetitions == 2:
        interval = 6
    else:
        interval = math.ceil(interval * easiness)
    return repetitions, easiness, interval


def number(value, field, default):
    value = default if value is None else value
    if isinstance(value, bool) or not isinstance(value, (int, float, str)):
        fail(f"{field} must be a number")
    try:
        return Fraction(str(value))
    except (ValueError, ZeroDivisionError):
        fail(f"{field} must be a number")


def grade(value, field):
    if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 5:
        fail(f"{field} must be an integer from 0 to 5")
    return value


def date(value, field):
    try:
        return dt.date.fromisoformat(value)
    except (TypeError, ValueError):
        fail(f"{field} must be a date YYYY-MM-DD")


def state(repetitions, easiness, interval, when):
    out = {"repetitions": repetitions, "easiness": float(round(easiness, 4)), "interval_days": interval}
    if when:
        out["next_review"] = (when + dt.timedelta(interval)).isoformat()
    return out


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    if "reviews" in request:
        reviews = request["reviews"]
        if not isinstance(reviews, list) or not reviews:
            fail("reviews must be a non-empty list")
        repetitions, easiness, interval, history = 0, Fraction(5, 2), 0, []
        for i, r in enumerate(reviews):
            if not isinstance(r, dict):
                fail(f"reviews[{i}] must be an object with date and quality")
            when = date(r.get("date"), f"reviews[{i}].date")
            repetitions, easiness, interval = step(grade(r.get("quality"), f"reviews[{i}].quality"), repetitions, easiness, interval)
            history.append({"date": when.isoformat(), "quality": r["quality"], **state(repetitions, easiness, interval, when)})
        print(json.dumps({"algorithm": "SM-2", **{k: v for k, v in history[-1].items() if k not in ("date", "quality")}, "history": history}))
        return
    quality = grade(request.get("quality"), "quality")
    repetitions = request.get("repetitions", 0)
    if not isinstance(repetitions, int) or isinstance(repetitions, bool) or repetitions < 0:
        fail("repetitions must be a non-negative integer")
    easiness = number(request.get("easiness"), "easiness", "2.5")
    interval = number(request.get("interval"), "interval", 0)
    if easiness < Fraction(13, 10) or interval < 0:
        fail("easiness must be at least 1.3 and interval non-negative")
    when = date(request["review_date"], "review_date") if "review_date" in request else None
    print(json.dumps({"algorithm": "SM-2", **state(*step(quality, repetitions, easiness, interval), when)}))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/srs_schedule/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/srs_schedule/1.0.0/srs_schedule.py