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.
| name | type | required | default | meaning |
|---|---|---|---|---|
quality | integer | no | 5 perfect, 4 after hesitation, 3 with difficulty, 0-2 forgotten. ≥ 0; ≤ 5 | |
repetitions | integer | no | 0 | ≥ 0 |
easiness | number | string | no | 2.5 | At least 1.3 |
interval | number | string | no | 0 | Days of the last interval |
review_date | string | no | pattern ^\d{4}-\d{2}-\d{2}$ | |
reviews | array of object | no | A 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