In one paragraph
Halflife is a flashcard app with two schedulers. Classic is SM-2, the 1987 formula Anki uses. Halflife is a trained model: after each review it predicts the half-life of that memory, the number of days until the chance of recall falls to fifty percent, and brings the card back on the day recall is predicted to hit your target. The model is half-life regression from Settles and Meeder's 2016 Duolingo paper, ported to PyTorch and trained on 12,854,226 real review traces. Classic is the default. The model still predicts every review in the background, so the two are compared on your own data, and it takes over only once a retrained version beats the formula on real reviews. It is free, there is no sign-up wall and no email. I built it because I am learning German and wanted a scheduler I could argue with.
How the scheduler works
Every card has a feature vector: the square roots of one plus the times seen, remembered and forgotten, the log of days since first seen, the log of the last response time, and a bias. The model is a weight per feature. Half-life is two to the power of the weighted sum, recall after a gap of d days is two to the power of minus d over the half-life, and the next review lands at h times log2 of one over the target retention. The whole model is six numbers in a JSON file, which is the point: it runs in NumPy behind FastAPI, and a TypeScript copy computes the same numbers if that function is slow or down. A parity test holds Python, TypeScript and the service to six decimal places on the same fixtures.
Every card in the app has a Why this date panel. It shows the features the model saw, the weights it multiplied them by, the half-life it predicted, recall today against the target, and which scheduler set the date.
The pipeline
The training data is the Duolingo learning traces, downloaded from Harvard Dataverse and verified by checksum. Splits hold out ten percent of learners, not rows, so every model is scored on people it never saw. Four schedulers are scored on identical splits: Leitner, SM-2, logistic regression, and half-life regression. On the paper's own split the port reproduces the released reference code to three decimals, and the paper's headline Leitner numbers: mean absolute error 0.235 and AUC 0.542.
On held-out learners, half-life regression gets a mean absolute error of 0.138 on recall, against 0.202 for SM-2 and 0.224 for Leitner. On spaced reviews with a gap of at least a day, where scheduling actually matters, its log-loss is 0.43 against 0.96 for SM-2 and 1.23 for Leitner. The report also runs a simulation: a synthetic learner with 1,000 cards over 90 days, each scheduler tuned until it holds ninety percent retention. That chart changed what shipped.
Retraining runs on GitHub Actions on the first of every month. It pulls reviews from users who share logs, never card text, fine-tunes the global weights and learns a per-card difficulty term, scores the candidate against the shipped model on a held-out slice, and opens a pull request with the new weights file only if held-out log-loss improves. I merge, Vercel deploys. It refuses to run on fewer than five hundred spaced reviews, and it says so in the log.
The app
Next.js 16 on the App Router, Tailwind v4, Drizzle and Neon Postgres, with the FastAPI function deployed in the same Vercel project. A first visit creates a silent guest account, so a stranger can be reviewing within seconds. Keeping progress means picking a username and a password. There is no email anywhere in the system, so there is no password reset by email either. At sign-up the app shows one recovery code once, and that code is the only way back in. Sessions live in Postgres, so signing out everywhere is one delete. Every review write is idempotent on user, card and timestamp, so a double tap or a retry never records two reviews. The review screen is space to reveal, one and two to grade. There is a dark theme, and day boundaries follow the reader's time zone. Sign-in and recovery are rate limited per address and per username. Session rows store no IP address or user agent. Accounts are capped at 50 decks and 20,000 cards. Lighthouse on mobile scores accessibility, best practices and SEO at 100 on every page and performance between 93 and 98, with no layout shift; desktop is 100 across the board. axe finds no violations on any screen in either theme.
What was hard
The data has almost no spacing effect in it. Recall in the Duolingo traces is 0.918 for same-session repeats and 0.878 after gaps of a hundred days or more. Three points across a year of gap, two points across forty prior reviews. The traces come from Duolingo's own scheduler, which chose which words came back and when, so the gap and the history are confounded with word difficulty and learner strength. A model trained on this learns that a card seen once has a half-life of ninety days, and that it barely grows with correct answers. The paper's own weights say the same thing, so the port is right and the data simply has little to teach about spacing.
The simulation makes the cost concrete. Holding ninety percent retention on a thousand cards costs SM-2 about 86 reviews a day and the shipped model 153, because its intervals never grow. So Classic is the default until a retrained model beats it on the app's own reviews, and the same promotion gate applies to the shipped model as to any later candidate. I had planned to write that the model won. A model trained on nearly thirteen million rows losing to a formula from 1987 turned out to be the more useful thing to have found.
The paper's loss function nearly hid this. It regresses toward the observed half-life as well as the observed recall, and 84 percent of rows have perfect recall, whose implied half-life is clipped at nine months. That term pulled every card toward the ceiling and gave three correct answers a shorter half-life than one. Switching the objective to per-trial log-loss with no half-life term gave sane signs and the best AUC of any variant, and it is the criterion the retraining gate promotes on.
Two bugs came from the framework doing what it is supposed to. A server action that revalidates the route makes Next re-render the page and ship it back with the response, so the review screen's empty-queue branch replaced "Session done" with "Nothing to review" two seconds after the last grade; the client session now owns its queue. And the server wrote day labels in UTC while the browser wrote them in Hyderabad, so "due today" hydrated into "due tomorrow" with a layout shift for anyone far from UTC; the browser now reports its zone in a cookie and both sides format in it.
Smaller things. Harvard Dataverse returns 403 to the default Python user agent. The released reference code does not reproduce the paper's positive half-life correlation for Leitner; my port agrees with the code and I have reported the code's numbers.
Better Auth cost three fixes of its own. It requires an email column even with usernames, so the app fills it with a placeholder on the reserved .invalid domain and nobody is ever asked for one. It stores the IP address and user agent on every session, which broke the promise of collecting no personal data, so a hook overwrites both with null. And its rate limiter keys on address alone, which would let many addresses each take ten guesses at one username, so I added per-username counts. One more: the session cookie cache kept a revoked session alive for five minutes after a recovery reset, so the cache is off.
Why the default is the formula
Shipping the model as the default would have been engineering for the resume. The numbers said it would schedule worse than SM-2 for the first weeks of anyone's use, and the whole point of the project was to be able to defend every choice in it. So the model earns the default the same way any future version does: by beating what is there on held-out reviews from real learners, in a pull request anyone can read.
What I would do differently
Get real flashcard reviews sooner. The two-week Duolingo window has a median of one review per learner-word, which is why version one ignores order and timing and why the sequence model is version two. The gates for that model are written: at least three percent better log-loss on fifty thousand of the app's own reviews, no worse calibration in any bin, fewer reviews per day at the same retention, and a Why this date panel that can still explain the prediction on one screen. If it fails any of them, the comparison goes in this page as a negative result.