# Zeitgeist Anki — FSRS-4.5 Algorithm Reference

Source of truth: `/media/HDD_1TB/Zeitgeist/anki.html` (client-side JavaScript embedded in the page). This is a custom implementation of the FSRS-4.5 scheduler, not Anki's legacy SM-2.

## Core model

Each card carries three FSRS variables:

- **Stability `S`** — expected number of days the memory remains stable.
- **Difficulty `D`** — how hard the card is, clamped to `[1, 10]`.
- **Retrievability `R(t)`** — probability of recall after `t` days since last review.

The scheduler targets **90 % desired retention** (`DESIRED_R = 0.9`).

## Default weights (`FSRS.W`)

```js
W = [0.4072, 1.1829, 3.1262, 15.4722, 7.2102, 0.5316,
     1.0651, 0.0234, 1.616, 0.1544, 1.0824,
     1.9813, 0.0953, 0.2975, 2.2042, 0.2407,
     2.9466, 0.5034, 0.6567]
```

These are the FSRS-4.5 defaults calibrated on ~10k Anki user histories. They are **not** re-trained on the user's personal review history.

## Tunable constants

| Constant | Value | Meaning |
|----------|-------|---------|
| `DESIRED_R` | 0.9 | Target retention probability |
| `MAX_INTERVAL` | 365 days | Hard cap on review interval |
| `FUZZ` | 0.05 | ±5 % random jitter to spread pile-ups |
| `LEECH_AT` | 8 lapses | Leech flag threshold |
| `STEPS_LEARN` | `[2, 1440]` minutes | New-card learning steps: 2 min, then 1 day |
| `STEPS_RELEARN` | `[15]` minutes | Relearn step after a lapse |
| `HARD_LEARN_MIN` | 10 min | Minimum "Hard" interval in learning |
| `HARD_MULTIPLIER` | 1.5 | Multiplier for "Hard" in higher steps/relearn |
| `GRAD_INTERVAL` | 2 days | Graduating interval after last learning step (Good) |
| `EASY_INTERVAL` | 4 days | Graduating interval with Easy |

## State machine

```
new → learning → review
           ↑      ↓ (lapse)
           └──── relearn
```

Card states: `new`, `learning`, `review`, `relearn`.

## Formulas

### Retrievability

```js
R(t, S) = (1 + t / (9 * S)) ^ -1
```

### Interval from stability

```js
interval(S, fuzz) = clamp(1, MAX_INTERVAL, round(9 * S * (1/DESIRED_R - 1) * (1 ± FUZZ)))
```

For rating-button previews `noFuzz = true` is passed so buttons don't jitter.

### Initial values on first rating

```js
initS(rating) = W[rating - 1]
initD(rating) = W[4] - (rating - 3) * W[5]
```

### Difficulty update

```js
dD     = -W[6] * (rating - 3)
dPrime = D + dD * (10 - D) / 9
D_new  = W[7] * initD(4) + (1 - W[7]) * dPrime
```

### Stability on successful recall (rating ≥ 2)

```js
hardPenalty = (rating === 2) ? W[15] : 1
easyBonus   = (rating === 4) ? W[16] : 1
factor      = exp(W[8]) * (11 - D) * S ^ -W[9] * (exp(W[10] * (1 - R)) - 1)
              * hardPenalty * easyBonus
S_new       = S * (1 + factor)
```

### Stability on lapse (rating = 1)

```js
S_new = W[11] * D ^ -W[12] * ((S + 1) ^ W[13] - 1) * exp(W[14] * (1 - R))
```

## Rating behavior

### New / Learning cards

| Rating | Action |
|--------|--------|
| 1 Again | Back to step 0, due in 2 min |
| 2 Hard | Current step × 1.5, at least 10 min, stay in step |
| 3 Good | Next learning step; after last step graduate to `review` with `GRAD_INTERVAL` |
| 4 Easy | Graduate immediately to `review` with `EASY_INTERVAL` |

On graduation the card receives:
- `state = 'review'`
- `stability = GRAD_INTERVAL` or `EASY_INTERVAL`
- `difficulty = initD(rating)`
- `interval` set accordingly
- `lastReview = now`

### Review cards

1. Compute elapsed days `t` since `lastReview`.
2. Compute `R = R(t, S)`.
3. Update `D` with the rating.
4. If rating = 1 (Again):
   - increment `lapses`
   - `S = stabilityForget(D, S, R)`
   - `state = 'relearn'`
   - `learningStep = 0`
   - `dueDate = now + STEPS_RELEARN[0]`
   - `interval = interval(S, false)` (no fuzz)
   - set `leech = true` if `lapses >= LEECH_AT`
5. If rating ≥ 2:
   - `S = stabilityRecall(D, S, R, rating)`
   - `interval = interval(S, fuzz)`
   - `lastReview = now`
   - `dueDate = now + interval * 86400000`

### Relearn cards

| Rating | Action |
|--------|--------|
| 1 Again | Step 0, due in 15 min |
| 2 Hard | Step × 1.5, stay in step |
| 3/4 Good/Easy | Next step or return to `review` with new interval from current stability |

## SM-2 migration

When an old card lacking `stability`/`difficulty` is first rated, `_migrate()` runs:

```js
stability  = max(0.5, interval || 1)
difficulty = clamp(1, 10, ((5.0 - easeFactor) / 3.7) * 9 + 1)
lastReview = dueDate - interval * 86400000
```

The legacy `easeFactor` field is kept for backward compatibility but no longer used after migration.

## IDs

```js
uid = Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
```

Base-36 timestamp + 4 random characters.

## Data layout in `data.json`

The Anki payload lives under the key `zeitgeist-anki-v2` and is **double JSON-encoded** (string value inside `data.json`). Decode twice.

```json
{
  "decks": {
    "<deckId>": {
      "id": "...",
      "name": "...",
      "color": "#...",
      "cards": [
        {
          "id": "...",
          "front": "...",
          "back": "...",
          "state": "new" | "learning" | "review" | "relearn",
          "interval": 0,
          "stability": null,
          "difficulty": null,
          "lastReview": null,
          "dueDate": null,
          "lapses": 0,
          "reviews": 0,
          "learningStep": 0,
          "easeFactor": 2.5,
          "leech": false
        }
      ]
    }
  }
}
```

## Pitfalls

- The Anki value is a JSON-string inside `data.json`. Always decode twice.
- The scheduler is **not personalized**: weights are static FSRS-4.5 defaults.
- `MAX_INTERVAL` is capped at 365 days, which is conservative for long-term retention.
- `STEPS_LEARN[1] = 1440` minutes means one "Good" press moves the card to the next day, then graduates.
- Learning and relearning use **minute-based steps**, not FSRS intervals.
- Fuzz is only applied during actual scheduling, not in the button preview (`_calcDue` uses `noFuzz = true`).

## Useful verification script

Read the current FSRS state across all decks:

```python
import json, statistics
ZG = '/media/HDD_1TB/Zeitgeist/data/data.json'
with open(ZG) as f:
    data = json.load(f)
anki = json.loads(data['zeitgeist-anki-v2'])
all_cards = [c for d in anki['decks'].values() for c in d.get('cards', [])]
print('total', len(all_cards))
print('states', {s: sum(1 for c in all_cards if c.get('state','new')==s) for s in {'new','learning','review','relearn'}})
review_cards = [c for c in all_cards if c.get('state') in ('review','relearn')]
if review_cards:
    print('avg stability', round(statistics.mean([c['stability'] for c in review_cards if c.get('stability')]), 2))
    print('avg difficulty', round(statistics.mean([c['difficulty'] for c in review_cards if c.get('difficulty')]), 2))
```
