Retention Model
Model trả lời một câu hỏi mà Learner Model không trả lời được: "cái em từng làm được, bây giờ em còn làm được không?"
Trang này mô tả cả Retention Model lẫn Retention Engine. Engine là phần tính (§3 đường ghi, §4 đường đọc, §5 refresh định kỳ); Model là phần lưu (§2). Tách hai trang sẽ khiến người đọc phải nhảy qua lại giữa công thức và chỗ nó ghi vào — mà toàn bộ cái khó của Retention nằm đúng ở mối nối đó.
Thiết kế: SDD-017 · Engine thuần: workers/api/src/modules/retention/engine.ts · I/O: service.ts · API: routes.ts · Test: engine.test.ts
1. Luật nền: Mastery ≠ Retention
Đây là quy tắc kiến trúc cứng, không phải lựa chọn thiết kế có thể thương lượng:
| Mastery | Retention | |
|---|---|---|
| Câu hỏi | Em đã học được chưa? | Em còn nhớ không? |
| Lưu ở | learner_skill_state.mastery | learner_retention.current_retention |
| Theo thời gian | Không bao giờ tự giảm | Giảm theo hàm mũ |
| Ai được ghi | Mastery Engine | Retention Engine |
Retention Engine không bao giờ ghi vào learner_skill_state. Nếu một PR làm điều đó, PR đó sai — kể cả khi test xanh.
Vì sao quan trọng đến vậy: nếu để mastery tự phai, hệ thống sẽ nói với đứa trẻ "con chưa biết cái này" trong khi sự thật là "con đã biết và đang quên dần". Hai câu đó dẫn tới hai hành động dạy học khác hẳn nhau: một bên là dạy lại từ đầu, một bên là nhắc 2 câu trong 3 phút. Và với đứa trẻ, hai câu đó cũng có sức nặng cảm xúc hoàn toàn khác.
Hệ quả thứ hai: quên ≠ chưa học bao giờ. Node có historical_mastery < 0.6 (CFG.MASTERED_MIN) không thuộc bức tranh trí nhớ — nó thuộc đường học. Không bao giờ xuất hiện trong review queue, groupLabel trả null.
2. Model lưu gì
Bảng learner_retention, khóa chính (learner_id, subject_id, target_type, target_id). Hiện target_type luôn là 'node' (Q-112) — cột để mở đường cho retention cấp skill/unit sau này.
| Cột | Kiểu | Ý nghĩa | Ai ghi |
|---|---|---|---|
historical_mastery | REAL | Đỉnh mastery từng đạt. Chỉ tăng, không bao giờ giảm | evidence hook |
current_retention | REAL | R₀ tại thời điểm last_exposure_at — không phải R lúc này (xem §4) | evidence hook |
retention_confidence | REAL | Độ tin của ước lượng, không phải độ chắc của trí nhớ | evidence hook + refresh |
stability_days | REAL | S — trí nhớ bền bao lâu. Đúng → tăng, sai → co | evidence hook |
last_exposure_at | TEXT | Anchor của đường quên: mọi lần chạm, kể cả sai/đoán bừa | evidence hook |
last_successful_retrieval_at | TEXT | Lần đúng gần nhất — dùng cho câu "N ngày chưa dùng" | evidence hook |
last_strong_evidence_at | TEXT | Lần independent/transfer gần nhất | evidence hook |
retrieval_count | INT | Số lần thử thật (không đếm exposure) | evidence hook |
successful_retrieval_count | INT | Số lần đúng — đầu vào của retention_confidence | evidence hook |
review_urgency | TEXT | NONE|LOW|MEDIUM|HIGH|CRITICAL — projection, có CHECK constraint | refresh (WF-17) |
next_review_earliest/ideal/latest | TEXT | Khoảng nên ôn, không phải một mốc cứng | refresh (WF-17) |
model_version | TEXT | retention-v1 | cả hai |
updated_at | TEXT | cả hai |
Index idx_retention_review(learner_id, subject_id, next_review_ideal).
Trạng thái sống nằm ở learner_retention, không ở learner_model_versions. Đường evidence (§3) chỉ ghi vào bảng này — không sinh version, vì retention đổi liên tục theo thời gian nên version hoá mỗi lần trả lời sẽ phình DB mà chẳng nói thêm điều gì.
Snapshot theo version có, nhưng đến từ đường khác: WF-17 chạy hằng ngày sinh một version model_kind='retention'. Hai đường, hai vai trò:
| Đường evidence | Đường WF-17 | |
|---|---|---|
| Kích hoạt | Learner trả lời một câu | Cron 04:00 ICT |
| Ghi | learner_retention (anchor + phái sinh) | learner_retention (chỉ phái sinh) + model version |
| Trả lời | "Trí nhớ giờ thế nào" | "Trí nhớ hôm đó thế nào" |
3. Cơ chế CẬP NHẬT (đường ghi)
3.1 Ai kích hoạt
Retention chỉ được cập nhật khi có bằng chứng mới về node đó. Không có đường nào khác. Ba hook, tất cả gọi cùng một hàm retentionStatementForEvidence():
| Nguồn bằng chứng | File | Dòng |
|---|---|---|
| Luyện tập (practice / lab) | modules/learning/routes.ts | ~265 |
| Chẩn đoán (diagnostic submit) | modules/knowledge/routes.ts | ~171 |
| Làm đề thi (exam attempt) | modules/exams/routes.ts | ~144 |
Không có hook nào ở forum, portrait, parent observation — niềm tin của người lớn không phải bằng chứng trí nhớ của đứa trẻ.
3.2 Vì sao trả về statement chứ không tự ghi
retentionStatementForEvidence() không chạy DB write. Nó trả về một D1PreparedStatement để caller gộp vào DB.batch() cùng với evidence + mastery update:
const retentionStmt = await retentionStatementForEvidence(env, {...});
await env.DB.batch([
insertEvidenceStmt, // learner_evidence
upsertMasteryStmt, // learner_skill_state
...(retentionStmt ? [retentionStmt] : []), // learner_retention
]);Lý do: một lần trả lời phải là một sự kiện nguyên tử. Nếu evidence ghi được mà retention ghi hỏng, model sẽ vĩnh viễn tin rằng lần trả lời đó chưa từng xảy ra — và không có cách nào phát hiện ra. Gộp batch loại bỏ hẳn lớp lỗi này.
3.3 Chống ghi trùng (idempotency)
if (a.eventId) {
const dup = await env.DB.prepare("SELECT 1 FROM learner_evidence WHERE event_id=?1")...;
if (dup) return null; // client retry → KHÔNG áp dụng lần hai
}Idempotency bám vào Evidence Registry, không tự dựng khóa riêng. Client bấm nộp hai lần, mạng retry, queue redeliver — retention chỉ dịch chuyển một lần. Đây là ràng buộc bắt buộc vì applyEvidence không giao hoán và không lũy đẳng: áp dụng hai lần cho kết quả khác một lần.
3.4 Trình tự tính
1. Đọc state cũ (hoặc null nếu lần đầu)
2. elapsed = ngày từ last_exposure_at tới now
3. rNow = decayedRetention(current_retention, S, elapsed) ← decay TRƯỚC khi áp evidence
4. spacingRatio = elapsed / S
5. tier = evidenceTier(correct, reliability, spacingRatio)
6. áp hiệu ứng tier lên (retention, stability) ← có 2 lớp giảm chấn, xem dưới
7. urgency snapshot (KHÔNG có ngữ cảnh goal/exam — xem §3.6)
8. tính next_review_earliest/ideal/latest
9. UPSERTBước 3 là điểm dễ sai nhất: phải decay tới hiện tại trước, rồi mới cộng hiệu ứng của lần trả lời này. Làm ngược lại thì một học sinh biến mất 3 tháng rồi quay lại làm đúng 1 câu sẽ được cộng thưởng lên trên giá trị của 3 tháng trước — model sẽ tin em ấy nhớ hơn thực tế rất nhiều.
3.5 Hai lớp giảm chấn
spacingWeight = min(1, elapsedDays) // làm lại trong cùng ngày ≈ không đổi stability
w = reliability // đoán bừa ≈ không dịch chuyển gì| Vì sao | Nếu thiếu |
|---|---|
spacingWeight | Cày 50 câu một buổi sẽ đẩy stability lên trời. Nhồi nhét không tạo trí nhớ dài hạn, và model không được phép giả vờ là có. |
w | Spam đoán bừa 20 câu trong 30 giây sẽ "đánh bóng" retention thành 0.95. Đây là lỗ hổng được phát hiện khi review SRC-065 và bịt bằng cách nhân mọi boost/penalty với reliability. |
Tương ứng: một lần sai không kéo retention về 0 — chỉ về min(R×0.5, 0.45) theo w. Bảo vệ context-failure: đứa trẻ mệt, đọc nhầm đề, bấm nhầm.
3.6 Vì sao review_urgency lúc ghi là "sai có chủ ý"
const urgency = urgencyFor({ ..., inGoal: false, blocksCount: 0, daysToExam: null });Lúc ghi evidence, hàm cố tình không truyền ngữ cảnh goal/lịch thi. Đó là snapshot theo retention thuần, dùng khi cần đọc nhanh. Giá trị đúng được tính lại ở hai chỗ có đủ ngữ cảnh: review queue (mỗi lần đọc) và refreshRetentionProjections(). Lý do: goal và lịch thi đổi liên tục theo những sự kiện chẳng liên quan gì đến node này — nhét chúng vào đường ghi evidence sẽ khiến giá trị lưu lỗi thời ngay sau khi ghi.
3.7 Độ tin cậy của ước lượng (retention_confidence)
Khác với confidence của mastery. retentionConfidence(successes, Δt, S):
volume = 1 − exp(−successes / 3)
recency = exp(−Δt / (4 × max(S_MIN, S)))
confidence = clamp01(volume × recency)Nhiều lần retrieval thành công thì chắc hơn; lâu không quan sát thì phai (hằng số 4·S — Audit #013 T-12 bắt được tham số này chưa có trong reference dù AS-05.4.5 đòi).
4. Cơ chế ĐỌC — lazy recalculation
current_retention trong DB KHÔNG phải retention hiện tại. Nó là R₀ tại mốc last_exposure_at. Retention lúc này luôn được tính khi đọc:
elapsed = daysBetween(last_exposure_at, now)
retention = decayedRetention(current_retention, stability_days, elapsed)Quyết định này (Q-113) đổi lấy một chút CPU lúc đọc để tránh phải quét toàn bộ learner mỗi đêm chỉ để trừ dần một con số. Trí nhớ phai liên tục, nên bất kỳ giá trị "đã lưu" nào cũng lỗi thời ngay khoảnh khắc sau.
Bẫy chết người
Không bao giờ ghi giá trị đã decay ngược vào current_retention mà không dời last_exposure_at. Làm vậy là decay hai lần: lần sau đọc sẽ decay tiếp từ giá trị đã decay. Sau vài chu kỳ, mọi node đều tụt về 0 và hệ thống sẽ bắt đứa trẻ ôn lại tất cả mọi thứ.
5. Refresh định kỳ (refreshRetentionProjections)
Trí nhớ phai theo thời gian, không theo hành động. Nghĩa là các trường phái sinh phụ thuộc thời gian sẽ lỗi thời kể cả khi học sinh không làm gì cả — và chính "không làm gì" mới là lúc cần nhắc.
Hàm refreshRetentionProjections(env, learnerId, now) (SDD-017 §15) tính lại:
| Cập nhật | Giữ nguyên |
|---|---|
review_urgency (lần này có đủ ngữ cảnh goal/blocks/exam) | current_retention |
next_review_earliest/ideal/latest | stability_days |
retention_confidence | last_exposure_at ← anchor, tuyệt đối không đụng |
Vì sao refresh nhiều lần không làm trôi lịch ôn: hàm mũ là memoryless. Re-anchor tại now với giá trị đã decay cho ra đúng cùng các mốc tuyệt đối như anchor cũ:
R(t) = R₀·e^(−t/S) ⟹ mốc R cắt ngưỡng x tính từ anchor cũ và anchor mới trùng nhauChạy 1 lần/ngày hay 100 lần/ngày đều ra cùng một lịch. Đây là điều kiện để refresh có thể chạy vô hại ở bất kỳ đâu.
Trả về RetentionSubjectView[] — snapshot 20 node đáng lo nhất mỗi môn, đủ để sau này đọc lại "hôm đó trí nhớ em thế nào" mà không phình DB.
Mốc trong snapshot cắt tới NGÀY, không tới mili-giây
next_review_ideal: w.ideal.slice(0, 10) // "2026-09-20", không phải "2026-09-20T04:12:33.481Z"Cột DB giữ mốc chính xác tới mili-giây; snapshot thì chỉ giữ tới ngày. Lý do nằm ở cơ chế versioning: snapshot được hash để quyết định "model có đổi không".
Mốc tới mili-giây trôi theo đúng khoảng thời gian giữa hai lần chạy — để nguyên thì mỗi lượt cron lại đẻ một version retention mới cho mọi learner, dù trí nhớ không đổi gì đáng kể. Sau một tháng là 30 version rác mỗi learner.
Cùng họ với thủ thuật hashContent của Planning Engine: thứ gì đổi mỗi lần chạy thì không được nằm trong hash.
Ai gọi nó — WF-17 Retention Refresh
| Đường | Chi tiết |
|---|---|
Cron 0 21 * * * (04:00 ICT) | scheduled() → runRetentionRefresh() → tối đa 200 learner/lượt |
| Chạy tay | POST /v1/admin/retention-refresh (role admin) |
Mỗi learner đi qua runRetentionEngine(), và khác với đường evidence, lượt này có sinh model version: saveModelVersion(model_kind: "retention"). Nhờ vậy mỗi ngày có một snapshot trả lời được câu "hôm đó trí nhớ em thế nào" — và vì version chỉ tăng khi hash đổi, ngày nào không có gì đổi thì không tốn version.
Thứ tự chọn learner: last_snapshot ASC, learner chưa từng có snapshot (NULL) lên đầu. Ai bị cắt vì cap 200 sẽ tự lên đầu hàng đợi hôm sau.
Mỗi learner là một step riêng (refresh:{learnerId}) trong workflow run — một learner lỗi không làm hỏng cả lượt. Xem schedules.
6. Cơ chế DÙNG (đường đọc)
6.1 Review Queue — GET /v1/learners/{id}/retention/review-queue
Tính lại mỗi lần đọc, không có bảng hàng đợi, không có backlog (REQ-INT-26).
với mỗi node có retention state:
BỎ nếu historical_mastery < 0.6 → chưa từng vững, thuộc đường học
BỎ nếu retention > 0.85 (T_NONE) → đang chắc, không cần làm gì
BỎ nếu days_since_touch < 1 → vừa chạm hôm nay (kể cả làm sai)
urgency = urgencyFor(retention, historical, inGoal, blocksCount, daysToExam)
BỎ nếu urgency == NONE
priority = ForgettingRisk × Importance × GoalRelevance × Timing
sắp xếp theo priority giảm dần → lấy top-k (mặc định 5, tối đa 10)Ngữ cảnh (reviewContext) lấy từ: blueprint của goal đang active (inGoal), unit_prereqs cấp Unit (blocksCount, SRC-396), semester_exam_schedule gần nhất (daysToExam).
Mỗi candidate mang theo:
| Trường | Ví dụ |
|---|---|
question_count | 1 câu (R>0.75) · 2 (R>0.6) · 3 (còn lại) · 2 nếu là probe |
estimated_minutes | question_count × 3 |
probe | true khi R < 0.75 và confidence < 0.4 → đo trước, đừng dạy lại (REQ-INT-27) |
reason | "Con từng làm tốt "Hệ số góc", nhưng đã 23 ngày chưa dùng. 2 câu ngắn để giữ thật chắc." |
probe là điểm tinh tế nhất của model. Khi hệ thống không chắc em còn nhớ hay không, hành động đúng là hỏi 2 câu để biết, chứ không phải bắt học lại cả bài. Bắt học lại thứ em vẫn nhớ là cách nhanh nhất để mất niềm tin của một đứa trẻ.
Không có khái niệm "quá hạn". Không đếm "N bài trễ", không cộng dồn. Nghỉ hai tuần rồi quay lại thì thấy 5 việc đáng làm nhất hôm nay, không thấy một núi nợ.
6.2 Bức tranh trí nhớ — GET /v1/learners/{id}/retention/summary
Gom theo 4 nhãn (REQ-INT-28), tổng thể và theo từng mạch kiến thức:
Nhãn (GroupLabel, retention/engine.ts) | Ngưỡng R | Hiển thị (phía app) |
|---|---|---|
solid | ≥ 0.80 | Đang chắc |
refresh | ≥ 0.65 | Cần nhắc lại sớm |
at_risk | ≥ 0.45 | Có nguy cơ quên |
relearn | < 0.45 | Nên ôn lại |
Nhãn đổi sang tiếng Anh từ SRC-600 (REQ-PLT-21: định danh kỹ thuật tiếng Anh); bốn chuỗi cũ dang_chac / nhac_lai / nguy_co_quen / nen_on_lai không còn xuất hiện trong API (Audit #013, T-7).
Node vừa chạm hôm nay bị loại khỏi danh sách chi tiết — tránh dòng vô nghĩa "đã 0 ngày chưa dùng".
6.3 Shaping theo vai — ai được thấy con số
Đây là ràng buộc bắt buộc, thi hành ở tầng API (canSeeInternal):
| Vai | Nhận được |
|---|---|
mentor / staff / admin | Đầy đủ: retention, historical_mastery, priority, urgency, probe, retention_confidence |
| Learner, phụ huynh | Chỉ label, days_since_used, question_count, estimated_minutes, reason |
Không hiện công thức, không hiện phần trăm, không dùng chữ "quên" với trẻ (SDD-017 §10). "Con quên 62% rồi" là câu vô ích và làm tổn thương; "đã 23 ngày chưa dùng, 2 câu là chắc lại" là câu hành động được.
6.4 Chỗ khác đang tiêu thụ
| Nơi | Dùng gì |
|---|---|
Cockpit (knowledge/routes.ts ~341) | topReviewCandidates(..., 2) — chèn 2 việc ôn vào việc hôm nay |
apps/learn | Review queue (bản shaped) |
apps/marlins | Summary theo nhãn — phụ huynh thấy nhãn + số ngày, không thấy số model |
apps/admin | retention có trong ModelKind để tra cứu |
7. Ví dụ chạy thật
Học sinh vững "Hằng đẳng thức" (mastery 0.85) rồi nghỉ hè.
| Ngày | Sự kiện | S | R (lúc đọc) | Nhãn / hành động |
|---|---|---|---|---|
| 0 | Làm đúng, độc lập (independent) | 6 → 9 | 0.90 | dang_chac |
| 14 | (không làm gì) | 9 | 0.90·e^(−14/9) = 0.19 | nen_on_lai, nhưng NONE nếu không thuộc goal nào và không có kỳ thi |
| 14 | Có kỳ thi sau 10 ngày | 9 | 0.19 | urgency +1 bậc → vào queue, 3 câu |
| 14 | Làm đúng sau spacing dài (Δt=14 ≥ S=9 → transfer) | 9 → ~24 | tiến tới sàn 0.94 | Lần sau nhắc muộn hơn nhiều |
Đây chính là spacing effect: ôn đúng lúc sắp quên làm trí nhớ bền hơn hẳn ôn khi vẫn còn nhớ rõ.
8. Bất biến — vi phạm là bug
- Retention không bao giờ ghi
learner_skill_state. historical_masterychỉ tăng.last_exposure_atchỉ tiến, và luôn đi cùng lần cập nhậtcurrent_retention.- Cùng một
event_idkhông được áp dụng hai lần. historical_mastery < 0.6→ luôngroupLabel = nullvàurgency = NONE.- Learner/parent không bao giờ nhận số model qua API.
- Retention thấp một mình không đủ để bắt ôn — phải có goal/prereq/kỳ thi.
- Refresh định kỳ chỉ ghi trường phái sinh; chạm vào anchor là decay hai lần (§4).
9. Kiểm chứng
modules/retention/engine.test.ts — test hành vi, cấm test bằng regex source (RISK-015). Phủ: decay theo thời gian, spacing effect, rapid-guess không đẩy được retention, sai một lần không về 0, MASTERED_MIN chặn never-learned, cửa sổ ôn nghịch đảo đúng.
modules/retention/periodic.test.ts — khoá bất biến của refresh định kỳ: chỉ đụng trường phái sinh, không dời anchor, chạy nhiều lần không trôi lịch ôn.
Gate: QG-005.
Trace
- REQ-INT-23 (tách model) · REQ-INT-24 (evidence tiers) · REQ-INT-25 (review window) · REQ-INT-26 (queue động, không backlog) · REQ-INT-27 (probe thay relearning) · REQ-INT-28 (nhãn không phán xét).
- REQ-INT-30 (refresh định kỳ — SRC-104).
- Nguồn: SRC-065 (PRD Retention Engine của chủ dự án), SRC-104, SRC-105.
- Thiết kế: SDD-017; liên quan SDD-002 §5/§7-8/§10/§18.
- Liên quan: Engine Reference §6 · Models · Schedules.