engram

package module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 18, 2026 License: MIT Imports: 4 Imported by: 0

README

engram

A storage-agnostic spaced-repetition engine for Go, wrapping the FSRS algorithm.

Why

github.com/open-spaced-repetition/go-fsrs implements only the FSRS scheduling algorithm — one card in, a schedule out. Everything an app actually needs around it — decks, due queues, daily new-item caps, review logs, statistics, multiple-choice quiz generation — everyone hand-rolls. engram is that missing layer. It has exactly one dependency: go-fsrs itself.

Design principles

  • Deterministic. Time is always an explicit now time.Time parameter; randomness is always an injected *rand.Rand. No hidden clock reads, no global state. Fully reproducible in tests.
  • Storage-agnostic. engram defines SkillStore and ReviewStore interfaces; the application owns persistence. An in-memory memstore ships for tests and small apps.
  • Generic over "skill". A skill is whatever unit you track (a language to recognise, a chord, a capital city) — engram schedules it and samples fresh content each review.
  • v0.x until the API settles. The surface is pinned but may evolve from real use before v1.0.

Install

go get github.com/supercakecrumb/engram

Requires Go 1.26 (the module targets go 1.26; the toolchain is fetched automatically).

Quickstart

Play a deterministic Romance-languages flashcard session:

go run ./examples/cli

It demonstrates the scheduler, due queue, quiz generation, and stats working together over the in-memory store.

Here's the core loop:

package main

import (
	"context"
	"fmt"
	"math/rand"
	"time"

	"github.com/supercakecrumb/engram"
	"github.com/supercakecrumb/engram/quiz"
)

func main() {
	sched := engram.NewScheduler() // retention 0.9, max interval 365d, deterministic
	now := time.Now()

	// A never-reviewed skill starts due immediately.
	card := sched.NewCardState(now)

	// Build a multiple-choice exercise from a deck of skills.
	deck := []engram.Skill{
		{ID: "sk-por", DeckID: "romance", Key: "por", Label: "Portuguese"},
		{ID: "sk-spa", DeckID: "romance", Key: "spa", Label: "Spanish"},
	}
	rng := rand.New(rand.NewSource(1))
	ex := quiz.Generate(rng, deck[0], deck, quiz.Content{ID: "c1", Payload: "Olá, tudo bem?"})

	// Grade an answer and advance the schedule.
	correct, rating := ex.Grade("por")
	next, review := sched.Next(card, now, rating)
	review.SkillID = ex.SkillID

	fmt.Printf("correct=%v next due in %s\n", correct, next.Due.Sub(now))
	_ = context.Background()
	_ = review
}

API tour

  • Root package engram: types Deck, Skill, CardState, Review, State, Rating; Scheduler (via NewScheduler with options WithRetention, WithMaxInterval, WithWeights) exposing NewCardState(now) and Next(cs, now, rating); the answer→rating policy RatingForAnswer(correct).
  • Queue: NextDue(items, newIntroducedToday, cfg, now) (due-first ordering, MaxNewPerDay cap) and CountNewIntroduced(log, now, loc).
  • Stats (pure functions over review logs): Accuracy, Streak, DueForecast.
  • Storage interfaces: SkillStore, ReviewStore; in-memory memstore.Store implements both.
  • quiz subpackage: Generate (seeded, shuffled, exactly one correct option), Exercise.Grade, and Confusion for "you mistake Portuguese for Spanish 40% of the time"-style analysis.

Status

v0.x, first consumer is a Telegram GeoGuessr language trainer. Local development targets Go 1.26.

License

MIT © Aurora Kiel — see LICENSE.

Documentation

Overview

Package engram is a storage-agnostic spaced-repetition engine that wraps go-fsrs (the Free Spaced Repetition Scheduler). It knows nothing about any particular storage backend, UI, or transport — callers own persistence via the SkillStore and ReviewStore interfaces and drive the engine explicitly.

The package is deterministic by design: time is always passed in as an explicit now time.Time parameter rather than read from the clock, and any randomness needed by callers (e.g. in the quiz subpackage) is supplied via an injected *rand.Rand. This makes scheduling, queueing, and quiz generation fully reproducible in tests.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Accuracy

func Accuracy(log []Review) float64

Accuracy is the share of reviews in log with Rating != Again (Hard, Good, and Easy all count as correct). Returns 0 for an empty log.

func CountNewIntroduced

func CountNewIntroduced(log []Review, now time.Time, loc *time.Location) int

CountNewIntroduced counts entries in log whose StateBefore is StateNew and whose ReviewedAt falls on the same calendar day as now, both converted to loc (nil defaults to time.UTC).

func DueForecast

func DueForecast(cards []CardState, days int, now time.Time, loc *time.Location) []int

DueForecast buckets cards by the calendar day (in loc, nil defaulting to time.UTC) their Due falls on, relative to now's day. Bucket 0 is "due today or earlier" — overdue cards clamp into today's bucket — bucket i (i>0) is now's day + i. Cards due beyond now's day + days - 1 are not counted. Returns a slice of length days, or an empty non-nil slice if days <= 0.

func RemainingIntroBudget added in v0.3.0

func RemainingIntroBudget(cap, introducedToday int) int

RemainingIntroBudget returns max(0, cap-introducedToday); cap==0 → math.MaxInt (unlimited). Small pure helper mirroring the review-cap arithmetic.

func Streak

func Streak(log []Review, now time.Time, loc *time.Location) int

Streak returns the number of consecutive calendar days (in loc, nil defaulting to time.UTC) with at least one review, counting back from now's day. If now's day itself has no review yet, the count still starts from yesterday instead of resetting to 0 — a day that "just hasn't been studied yet" doesn't erase an otherwise-current streak. Returns 0 if log is empty or neither today nor yesterday (relative to now) has a review.

Types

type CardState

type CardState struct {
	Due        time.Time
	Stability  float64
	Difficulty float64
	Reps       int
	Lapses     int
	State      State
	LastReview time.Time // zero if never reviewed
}

CardState is the per-user FSRS memory state for one skill.

type ContentID

type ContentID string

ContentID identifies a piece of quiz content.

type Deck

type Deck struct {
	ID   DeckID
	Name string
}

Deck groups related skills (e.g. "Romance languages").

type DeckID

type DeckID string

DeckID identifies a Deck.

type IntroConfig added in v0.3.0

type IntroConfig struct {
	MaxNewPerDay int // 0 = unlimited
}

IntroConfig configures the daily introduction budget.

type IntroItem added in v0.3.0

type IntroItem struct {
	SkillID   SkillID
	Lifecycle Lifecycle
}

IntroItem pairs an item id with its lifecycle for intro selection.

func NextIntroductions added in v0.3.0

func NextIntroductions(items []IntroItem, introducedToday int, cfg IntroConfig) []IntroItem

NextIntroductions returns up to the remaining daily budget of items whose Lifecycle == LifecycleNew, PRESERVING the caller's slice order (which encodes priority). introducedToday is how many intros were already delivered today. Deterministic: no shuffling, stable selection. Returns an empty non-nil slice when the budget is exhausted or nothing is New.

type IntroOutcome added in v0.3.0

type IntroOutcome int8

IntroOutcome is the user's response to an introduction card (the three buttons).

const (
	IntroGotIt  IntroOutcome = iota // → Introduced, fresh FSRS card (StateNew, due now)
	IntroKnown                      // → Known, no card, never quizzed
	IntroTestMe                     // → Reviewing, FSRS card seeded with high initial stability
)

type Lifecycle added in v0.3.0

type Lifecycle int8

Lifecycle is the gating state of an item for one user. It is ORTHOGONAL to CardState (the FSRS memory state) and is persisted as a sibling field, never inside CardState — CardState must remain a lossless mirror of fsrs.Card.

const (
	LifecycleNew        Lifecycle = iota // 0: not introduced; no card; eligible for the intro queue; never quizzed
	LifecycleIntroduced                  // 1: intro acknowledged; card exists; FSRS State New/Learning (not yet graduated)
	LifecycleReviewing                   // 2: graduated into durable review; FSRS State Review/Relearning
	LifecycleKnown                       // 3: user asserted mastery; terminal; no active card; reversible later
)

func LifecycleFor added in v0.3.0

func LifecycleFor(cs CardState) Lifecycle

LifecycleFor derives the Introduced/Reviewing split from a card's FSRS state. Known and New are lifecycle-only (no card) and are never returned here; the caller sets those explicitly on the intro outcome. This keeps the coarse progress label cheap to store yet always consistent with the card.

func (Lifecycle) InFSRS added in v0.3.0

func (l Lifecycle) InFSRS() bool

InFSRS reports whether an item in this lifecycle carries a live FSRS card (Introduced or Reviewing). New and Known items do not.

type QueueConfig

type QueueConfig struct {
	MaxNewPerDay int // 0 = unlimited
}

QueueConfig configures NextDue's new-card cap.

type QueueItem

type QueueItem struct {
	Skill Skill
	Card  CardState
}

QueueItem pairs a Skill with its current FSRS card state, as loaded from a SkillStore for the skills in a deck.

func NextDue

func NextDue(items []QueueItem, newIntroducedToday int, cfg QueueConfig, now time.Time) (item QueueItem, ok bool)

NextDue picks the next item to study out of items. A due-or-overdue review-state card (State != StateNew, Due <= now) always wins, choosing the earliest Due (ties keep whichever was encountered first, for determinism). Only when no review-state card is due is a new (StateNew) card offered instead — again the earliest-Due one whose Due <= now — and only while newIntroducedToday is below cfg.MaxNewPerDay (0 means unlimited). ok=false with a zero QueueItem means nothing is studyable right now.

func NextReview added in v0.3.0

func NextReview(items []QueueItem, now time.Time) (QueueItem, bool)

NextReview picks the next due card from items, all of which already carry an FSRS card (Lifecycle Introduced or Reviewing — the app filters New/Known out before calling). A due review/relearning card wins by earliest Due; otherwise the earliest-due Learning-or-New card. There is NO daily-new cap: novelty is gated upstream at introduction. ok=false means nothing is due at now.

type Rating

type Rating int

Rating is the grade a reviewer gives when answering a card.

const (
	Again Rating = iota + 1
	Hard
	Good
	Easy
)

func RatingForAnswer

func RatingForAnswer(correct bool) Rating

RatingForAnswer is the v1 answer→rating policy: wrong → Again, correct → Good.

type Review

type Review struct {
	SkillID          SkillID
	Rating           Rating
	ReviewedAt       time.Time
	StabilityBefore  float64
	DifficultyBefore float64
	StabilityAfter   float64
	DifficultyAfter  float64
	StateBefore      State
	ScheduledDays    int
	ElapsedDays      int
}

Review is one append-only review-log entry. SkillID is set by the caller.

type ReviewStore

type ReviewStore interface {
	// Append records one review-log entry.
	Append(ctx context.Context, r Review) error
	// Log returns the review entries at or after since, in the order the store
	// chooses (callers that need ordering should sort).
	Log(ctx context.Context, since time.Time) ([]Review, error)
}

ReviewStore is the append-only review log for the single user this store is scoped to.

type Scheduler

type Scheduler struct {
	// contains filtered or unexported fields
}

Scheduler wraps go-fsrs/v3 and applies engram's fixed defaults (RequestRetention 0.9, MaximumInterval 365 days, fuzz disabled for determinism).

func NewScheduler

func NewScheduler(opts ...SchedulerOption) *Scheduler

NewScheduler builds a Scheduler starting from go-fsrs's defaults, then overrides MaximumInterval to engram's contract default of 365 days (go-fsrs defaults to 36500), and finally applies opts. EnableFuzz is always left false: fuzz injects its own randomness outside the caller's control, which would break engram's determinism guarantee.

func (*Scheduler) Introduce added in v0.3.0

func (s *Scheduler) Introduce(o IntroOutcome, now time.Time) (life Lifecycle, cs CardState, hasCard bool)

Introduce applies an introduction outcome at now and returns the resulting lifecycle, the CardState to persist, and hasCard (false for IntroKnown, where the returned CardState is the zero value). Deterministic; depends only on the scheduler's weights and now.

func (*Scheduler) NewCardState

func (s *Scheduler) NewCardState(now time.Time) CardState

NewCardState returns the state for a never-reviewed skill: StateNew, zero reps/lapses/stability/difficulty, zero LastReview, and Due set to now (due immediately).

func (*Scheduler) Next

func (s *Scheduler) Next(cs CardState, now time.Time, r Rating) (CardState, Review)

Next applies rating r to cs at time now and returns the updated card state plus the corresponding review-log entry. Review.SkillID is left zero; the caller fills it in.

func (*Scheduler) SeedCardKnown added in v0.3.0

func (s *Scheduler) SeedCardKnown(now time.Time) CardState

SeedCardKnown returns a CardState for an item the user asserts they already know but still wants tested occasionally: equivalent to a fresh card that received a first Easy rating at now. Concretely (go-fsrs default weights): State=StateReview, Stability≈w[3]≈15.69 (days), Difficulty=the Easy-initial difficulty, Reps=1, Lapses=0, LastReview=now, Due≈now+16d at retention 0.9. NO review-log entry is produced — an introduction is not a review. Deterministic. Because SeedCardKnown reuses Next(_, Easy), it automatically tracks any WithWeights/WithRetention override.

type SchedulerOption

type SchedulerOption func(*Scheduler)

SchedulerOption configures a Scheduler built by NewScheduler.

func WithMaxInterval

func WithMaxInterval(days int) SchedulerOption

WithMaxInterval sets the maximum interval in days between reviews (default 365).

func WithRetention

func WithRetention(r float64) SchedulerOption

WithRetention sets the target request retention (default 0.9).

func WithWeights

func WithWeights(w []float64) SchedulerOption

WithWeights overrides the FSRS model weights (default: go-fsrs defaults). If w has fewer than 19 elements, only the leading len(w) default weights are overridden and the rest keep their default values; if w has more than 19, the extras are ignored. This never panics.

type Skill

type Skill struct {
	ID     SkillID
	DeckID DeckID
	Key    string // app-defined answer key, e.g. ISO-639-3 "por"
	Label  string // human label, e.g. "Portuguese"
}

Skill is a single trackable unit of spaced repetition (e.g. "recognise Portuguese in the Romance group").

type SkillID

type SkillID string

SkillID identifies a Skill.

type SkillStore

type SkillStore interface {
	// Skills returns every skill in the given deck.
	Skills(ctx context.Context, deck DeckID) ([]Skill, error)
	// Card returns the stored card state for a skill. The bool is false when
	// the skill has never been seen (no row), in which case the caller should
	// treat it as a fresh card via Scheduler.NewCardState.
	Card(ctx context.Context, skill SkillID) (CardState, bool, error)
	// PutCard upserts the card state for a skill.
	PutCard(ctx context.Context, skill SkillID, cs CardState) error
}

SkillStore persists the set of skills in a deck and the per-skill card state for the single user this store is scoped to.

type State

type State int

State is the FSRS memory state of a card.

const (
	StateNew State = iota
	StateLearning
	StateReview
	StateRelearning
)

Directories

Path Synopsis
examples
cli command
Command cli is a small, deterministic, self-playing demo of the engram spaced-repetition engine: it builds a "Romance languages" deck, runs a simulated study session through the scheduler + queue + quiz + memstore packages, and prints a stats summary at the end.
Command cli is a small, deterministic, self-playing demo of the engram spaced-repetition engine: it builds a "Romance languages" deck, runs a simulated study session through the scheduler + queue + quiz + memstore packages, and prints a stats summary at the end.
Package memstore is an in-memory implementation of engram's SkillStore and ReviewStore interfaces, intended for tests and small apps.
Package memstore is an in-memory implementation of engram's SkillStore and ReviewStore interfaces, intended for tests and small apps.
Package quiz builds multiple-choice exercises over engram skills and grades the answers.
Package quiz builds multiple-choice exercises over engram skills and grades the answers.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL