はじめに:「今日やるべきカード」はどう決まるか
前回の記事では、知らない言葉をポイっと投げ込んでAIに補完させる運用を紹介した。
今回は、その「投げ込んだカード」を使って繰り返し復習する仕組みの中身を書く。
Cobo Memo は「スペーシング復習(間隔反復)」を採用している。アプリを開くと、今日復習すべきカードが自動で出てくる。復習する順番もタイミングも、自分で考えなくていい。
この仕組みをどう設計・実装したか——というのがこの記事のテーマだ。
「スペーシング復習」とは何か
エビングハウスの忘却曲線は有名だ。人は学習直後から急速に忘れ、時間が経つほど忘却が緩やかになる。
この特性を逆手に取ったのが間隔反復だ。
- 覚えたばかりのカード → 短い間隔で何度も復習
- 定着してきたカード → 間隔を延ばして効率化
- 忘れたカード → 間隔をリセットして再学習
AnkiのSM-2アルゴリズムが有名で、多くのフラッシュカードアプリはこれをベースにしている。Cobo Memoもこの設計思想を採用した。
システム全体のアーキテクチャ
「どこでアルゴリズムを動かすか」という設計判断から話す。
Flutter App
│
│ 学習セッション終了
│ (カードごとの正誤・時間を送信)
▼
DynamoDB(LearningSessionテーブル)
│
│ DynamoDB Streams(INSERT イベント)
▼
Lambda(insertIntoSQS) ← トリガー専用
│
│ SQSにエンキュー
▼
SQS(キュー)
│
│ 非同期で処理
▼
Lambda(create/handler.py) ← アルゴリズム本体
│
├── MasteryStatusテーブル(カードごとの習熟状態)を更新
└── LearningSummaryテーブル(学習統計)を更新
アルゴリズムはFlutter側(クライアント)ではなく、Lambda(Python)側で動かしている。
理由は3つだ:
- 複数デバイスの整合性:モバイルとタブレットで同じカードを学習した場合、状態の計算を1箇所で行わないと食い違いが起きる
- ロジックの変更容易性:アプリのアップデートなしにアルゴリズムを調整できる
- 非同期でUXを妨げない:セッション保存後、Lambdaが非同期でMasteryStatusを更新するので、ユーザーは待たされない
SQSを挟んでいるのは、DynamoDB Streamsからの直接起動よりリトライと流量制御がしやすいためだ。
状態遷移の設計
カードの学習状態を7つに分けた。
learning
│
│ 累積正解率70%以上で正解
▼
relearning
│
│ 累積正解率85%以上で正解
▼
young1(3日後)→ young2(7日後)→ young3(14日後)
→ youngmature(30日後)→ mature(Ease Factorで動的)
※ どのステートでも不正解 → 累積正解率に応じてペナルティ → 翌日復習
# 状態の種類
# learning : 学習中(まだ定着していない)
# relearning : 再学習中(learning卒業後に不正解が続いた)
# young1 : 定着化中(初期) 次回: 3日後
# young2 : 定着化中(中期) 次回: 7日後
# young3 : 定着化中(後期) 次回: 14日後
# youngmature : ほぼ定着 次回: 30日後
# mature : 完全定着 次回: Ease Factorで動的
Ankiの標準的なステートより細かく分けた理由はユーザーへの「進んでいる感」の提供だ。learning と mature の2段階だけだと、「なかなかmaturedにならない」という挫折感が生まれやすい。7段階にすることで、少しずつ進んでいることが視覚的にわかる。
アルゴリズムの実装(Lambda Python)
compute_mastery() 関数がアルゴリズムの中心だ。
基本の入出力
def compute_mastery(
old: Dict[str, Any], # 既存のMasteryStatus(DynamoDBから取得)
attempts: int, # セッションでの試行回数
correct: int, # セッションでの正解数
local_today: date # ユーザーのローカルタイムゾーンの今日
) -> Dict[str, Any]:
"""
改善された忘却曲線アルゴリズム(Anki/SM-2ベース)
"""
戻り値にはこれらのフィールドが含まれる:
return {
'totalAttempt': new_total, # 累積試行数
'totalCorrect': new_corr, # 累積正解数
'progressRate': cumulative_rate, # 累積正解率
'lastLearnedTime': today.isoformat(),
'nextReviewDate': next_date.isoformat(), # 次回復習日
'state': state, # 7段階の習熟状態
'daysElapsedSinceMature': days_elapsed, # matureまでの進捗段階数
'easeFactor': ease_factor, # SM-2のEase Factor
}
Ease Factor(難易度係数)
SM-2の核心の一つが easeFactor だ。デフォルト2.5で、正解が続くと増加し、間違えると減少する。mature以降の復習間隔の計算に使う。
# Ease Factor(難易度係数)の調整
# デフォルト2.5、最大3.0、最小1.3(Anki標準)
if session_rate == 1.0:
# 完全正解:Ease Factorを増加
ease_factor = min(old_ease + 0.15, 3.0)
else:
wrong_count = attempts - correct
if wrong_count == 1 and attempts <= 3:
# 少ない問題数で1問だけミス:わずかに減少
ease_factor = max(old_ease - 0.05, 1.3)
elif session_rate >= 0.8:
ease_factor = max(old_ease - 0.05, 1.3)
elif session_rate >= 0.5:
ease_factor = max(old_ease - 0.1, 1.3)
else:
ease_factor = max(old_ease - 0.2, 1.3)
Ease Factorが高いカード(よく正解する)は間隔が長くなり、低いカード(よく間違える)は間隔が短く保たれる。個人の苦手・得意に合わせてアルゴリズムが自動調整される。
不正解時のペナルティ設計
不正解時の処理が設計の肝だ。「1回間違えたらリセット」ではなく、累積正解率に応じて段階的にペナルティを調整している。
if session_rate < 1.0:
# 1問でも間違えた場合:翌日復習
if cumulative_rate < 0.6:
# 累積60%未満:完全リセット(まだ定着していない)
state = 'learning'
days_elapsed = 0
elif cumulative_rate < 0.7:
# 累積70%未満:2段階戻す
state = 'relearning' if days_diff >= 7 else 'learning'
days_elapsed = max(0, old_days - 2)
elif cumulative_rate < 0.8:
# 累積80%未満:1段階戻す
state = 'relearning' if days_diff >= 7 else 'learning'
days_elapsed = max(0, old_days - 1)
else:
# 累積80%以上:維持(十分定着しているので一時的な忘れと判断)
state = old.get('state', 'learning')
days_elapsed = old_days
# 不正解の場合は基本的に翌日復習
next_date = today + timedelta(days=1)
累積正解率80%以上のカードは、1回間違えても進捗が維持される。これは「何十回も正解してきたカードを、疲れている日に1回間違えたらゼロに戻る」という理不尽さを避けるための設計だ。
完全正解時の進捗
else:
# 完全正解の場合:進捗を許可
if cumulative_rate < 0.7:
state = 'learning'
days_elapsed = 0
next_date = today + timedelta(days=1)
elif cumulative_rate < 0.85:
state = 'relearning'
days_elapsed = 0
next_date = today + timedelta(days=2)
else:
# 累積85%以上:mature段階へ進捗
days_elapsed = old_days + 1
if days_elapsed == 1:
state = 'young1'
next_date = today + timedelta(days=3)
elif days_elapsed == 2:
state = 'young2'
next_date = today + timedelta(days=7)
elif days_elapsed == 3:
state = 'young3'
next_date = today + timedelta(days=14)
elif days_elapsed == 4:
state = 'youngmature'
next_date = today + timedelta(days=30)
elif days_elapsed == 5:
state = 'mature'
# Ease Factorを使用した動的間隔(最大90日)
interval = int(30 * ease_factor)
next_date = today + timedelta(days=min(interval, 90))
else:
state = 'mature'
# 長期記憶:指数的増加(最大180日)
base_interval = 60
interval = int(base_interval * (ease_factor ** (days_elapsed - 5)))
next_date = today + timedelta(days=min(interval, 180))
mature 以降の間隔は easeFactor を使って指数的に増加する。Ease Factorが2.5のカードなら:
- mature初回:30 × 2.5 = 75日後
- mature2回目:60 × 2.5¹ = 150日 → 上限180日
- mature3回目:60 × 2.5² = 375日 → 上限180日
つまり、十分に定着したカードは半年に1回の頻度で出てくるだけになる。
NextReviewDateに達していない「早期復習」の扱い
復習期日より前に自主練習した場合は、統計のみ更新して進捗は動かさないという設計にした。
# NextReviewDateに達していない場合は統計のみ更新
if today < next_review_date:
return {
'totalAttempt': new_total,
'totalCorrect': new_corr,
'progressRate': cumulative_rate,
'lastLearnedTime': today.isoformat(),
'nextReviewDate': next_review_str, # 変更しない
'state': old.get('state', 'learning'), # 変更しない
'daysElapsedSinceMature': int(old.get('daysElapsedSinceMature', 0)),
'easeFactor': float(old.get('easeFactor', 2.5)),
}
「前日に予習として全部やって翌日の復習を飛ばせる」という抜け道を防ぐための設計だ。
DynamoDBへの保存設計
MasteryStatus テーブルが習熟状態を管理する。
PK: userId(S)
SK: cardId(S)
主な属性:
- nextReviewDate(S): "2026-04-01"形式
- state(S) : "young1"など
- progressRate(N) : 0.0〜1.0
- daysElapsedSinceMature(N): mature段階の進捗
- easeFactor(N) : 難易度係数
- totalAttempt(N) : 累積試行数
- totalCorrect(N) : 累積正解数
Flutter側で「今日のカード」を取り出す
MasteryStatusはDynamoDBにあるが、オフライン優先設計のため、FlutterアプリはローカルのSQLiteにも同期している。「今日のカード」はSQLiteから取り出す。
Future<List<String>> getTodayCardIds(String userId) async {
final db = await database;
final today = DateTime.now().toIso8601String().substring(0, 10);
final rows = await db.query(
'MasteryStatus',
columns: ['cardId'],
where: 'userId = ? AND nextReviewDate <= ? AND state != ?',
whereArgs: [userId, today, 'mature'],
orderBy: 'progressRate ASC', // 習熟度が低いものを先に
);
return rows.map((r) => r['cardId'] as String).toList();
}
matureのカードを除外しているのではなく、nextReviewDateで絞り込んでいるので、matureでも期日が来れば出題される。
「暗記学習モード OFF」の設計
Cobo Memoには通常の復習とは別に、「練習モード」がある。練習モードでは習熟状態を動かしたくない(純粋に練習したいだけ)。
# 暗記学習モードに応じて処理を分岐
if memorization_mode:
# 暗記学習ON:通常の進捗更新
stats = compute_mastery(old, cnt['attempts'], cnt['correct'], local_today)
else:
# 暗記学習OFF:統計のみ更新(state, nextReviewDate等は変更しない)
stats = compute_mastery_stats_only(old, cnt['attempts'], cnt['correct'], local_today)
def compute_mastery_stats_only(old, attempts, correct, local_today):
"""練習モード:totalAttempt/totalCorrect/progressRateのみ更新"""
new_total = int(old.get('totalAttempt', 0)) + attempts
new_corr = int(old.get('totalCorrect', 0)) + correct
return {
'totalAttempt': new_total,
'totalCorrect': new_corr,
'progressRate': new_corr / new_total if new_total else 0.0,
'lastLearnedTime': local_today.isoformat(),
# 以下は変更しない
'nextReviewDate': old.get('nextReviewDate', local_today.isoformat()),
'state': old.get('state', 'learning'),
'daysElapsedSinceMature': int(old.get('daysElapsedSinceMature', 0)),
'easeFactor': float(old.get('easeFactor', 2.5)),
}
タイムゾーンの扱い
地味に重要な設計ポイントがタイムゾーンだ。
「今日」という概念はユーザーのローカルタイムゾーンに依存する。日本時間の0:30に学習した場合、UTC基準だと「前日」になってしまう。
Lambdaはサーバー上で動くのでローカルタイムゾーンを知らない。そのため、Flutterアプリからセッションデータにタイムゾーンオフセットをつけて送信し、Lambda側でローカル日付を再現している。
def parse_timezone(offset_str: str) -> timezone:
"""+09:00 や -07:00 形式を timezone オブジェクトに変換"""
sign = 1 if offset_str[0] == '+' else -1
hours, minutes = map(int, offset_str[1:].split(':'))
return timezone(sign * timedelta(hours=hours, minutes=minutes))
# セッションデータから localOffset を取得
dt_utc = datetime.fromisoformat(session['createdAt'].replace('Z', '+00:00'))
if 'localOffset' in session:
dt = dt_utc.astimezone(parse_timezone(session['localOffset']))
local_today = dt.date() # これをアルゴリズムに渡す
実装してわかったこと
アルゴリズムより「体感的な公平さ」が先
最初はSM-2のease factorの計算式を正確に再現しようとした。しかし使ってみると、数式の正確さより 「このルールは体感的に公平か?」 の方が継続率に直結した。
「何十回も正解したのに1回間違えたらリセット」はユーザーが不満に思う。「期日前に自主練習したら次の期日が縮まる」も理不尽だ。アルゴリズムの正確性より、こういった「体感的な納得感」を設計に入れる方が難しく、大事だった。
非同期処理はSQSで正解だった
DynamoDB Streams → SQS → Lambda という構成は、最初は「複雑すぎるか」と思った。でも実際には:
- Lambdaが重い処理をしていてもアプリはサクサク動く
- 失敗時の再試行が自動
- 流量制御が簡単
という恩恵が大きかった。Flutter側は「セッションをDynamoDBに書き込む」だけで終わり、あとはバックエンドが非同期に処理する。ユーザー体験とシステムの堅牢性が両立できた。
まとめ
| 設計要素 | 採用した方針 |
|---|---|
| アルゴリズムの場所 | Lambda(Python)側で計算、Flutter側はDynamoDB/SQLiteを参照するだけ |
| 基本設計 | Anki/SM-2をベースに四択クイズ向けに改修 |
| 状態数 | 7段階(learning/relearning/young1〜3/youngmature/mature) |
| Ease Factor | デフォルト2.5、最大3.0、最小1.3(SM-2標準) |
| 不正解時ペナルティ | 累積正解率に応じて段階的(80%以上なら維持) |
| mature以降の間隔 | Ease Factorで動的計算(最大180日) |
| 早期復習 | 統計のみ更新、進捗は変えない |
| タイムゾーン | クライアントからoffsetを送信、Lambda側でローカル日付を再現 |
| 非同期化 | DynamoDB Streams → SQS → Lambda |
「スペーシング復習」は概念としては単純だが、実装の細部——タイムゾーン、ペナルティの公平感、複数デバイスの整合性——に設計の難しさが詰まっている。同じ課題に取り組む方の参考になれば。
Cobo Memo(iOS / Android )
前の記事:AIフラッシュカード続編:知らない言葉を『ポイっと投げ込む』だけ。空欄はGeminiが埋めてくれる
技術スタック: Flutter / AWS Amplify Gen2 / DynamoDB Streams / SQS / Lambda (Python)

