Skip to content

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:

MasteryRetention
Câu hỏiEm đã học được chưa?Em còn nhớ không?
Lưu ởlearner_skill_state.masterylearner_retention.current_retention
Theo thời gianKhông bao giờ tự giảmGiảm theo hàm mũ
Ai được ghiMastery EngineRetention 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ộtKiểuÝ nghĩaAi ghi
historical_masteryREALĐỉnh mastery từng đạt. Chỉ tăng, không bao giờ giảmevidence hook
current_retentionREALR₀ tại thời điểm last_exposure_at — không phải R lúc này (xem §4)evidence hook
retention_confidenceREALĐộ tin của ước lượng, không phải độ chắc của trí nhớevidence hook + refresh
stability_daysREALS — trí nhớ bền bao lâu. Đúng → tăng, sai → coevidence hook
last_exposure_atTEXTAnchor của đường quên: mọi lần chạm, kể cả sai/đoán bừaevidence hook
last_successful_retrieval_atTEXTLần đúng gần nhất — dùng cho câu "N ngày chưa dùng"evidence hook
last_strong_evidence_atTEXTLần independent/transfer gần nhấtevidence hook
retrieval_countINTSố lần thử thật (không đếm exposure)evidence hook
successful_retrieval_countINTSố lần đúng — đầu vào của retention_confidenceevidence hook
review_urgencyTEXTNONE|LOW|MEDIUM|HIGH|CRITICAL — projection, có CHECK constraintrefresh (WF-17)
next_review_earliest/ideal/latestTEXTKhoảng nên ôn, không phải một mốc cứngrefresh (WF-17)
model_versionTEXTretention-v1cả hai
updated_atTEXTcả 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ạtLearner trả lời một câuCron 04:00 ICT
Ghilearner_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ứngFileDò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:

ts
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) ​

ts
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. UPSERT

Bướ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ì saoNếu thiếu
spacingWeightCà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ó.
wSpam đ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ủ ý" ​

ts
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):

text
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:

ts
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ậtGiữ nguyên
review_urgency (lần này có đủ ngữ cảnh goal/blocks/exam)current_retention
next_review_earliest/ideal/lateststability_days
retention_confidencelast_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 nhau

Chạ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 ​

ts
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 ​

ĐườngChi tiết
Cron 0 21 * * * (04:00 ICT)scheduled() → runRetentionRefresh() → tối đa 200 learner/lượt
Chạy tayPOST /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ườngVí dụ
question_count1 câu (R>0.75) · 2 (R>0.6) · 3 (còn lại) · 2 nếu là probe
estimated_minutesquestion_count × 3
probetrue 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 RHiển thị (phía app)
solid≥ 0.80Đang chắc
refresh≥ 0.65Cần nhắc lại sớm
at_risk≥ 0.45Có nguy cơ quên
relearn< 0.45Nê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):

VaiNhận được
mentor / staff / adminĐầy đủ: retention, historical_mastery, priority, urgency, probe, retention_confidence
Learner, phụ huynhChỉ 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ơiDùng gì
Cockpit (knowledge/routes.ts ~341)topReviewCandidates(..., 2) — chèn 2 việc ôn vào việc hôm nay
apps/learnReview queue (bản shaped)
apps/marlinsSummary theo nhãn — phụ huynh thấy nhãn + số ngày, không thấy số model
apps/adminretention 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àySự kiệnSR (lúc đọc)Nhãn / hành động
0Làm đúng, độc lập (independent)6 → 90.90dang_chac
14(không làm gì)90.90·e^(−14/9) = 0.19nen_on_lai, nhưng NONE nếu không thuộc goal nào và không có kỳ thi
14Có kỳ thi sau 10 ngày90.19urgency +1 bậc → vào queue, 3 câu
14Làm đúng sau spacing dài (Δt=14 ≥ S=9 → transfer)9 → ~24tiến tới sàn 0.94Lầ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 ​

  1. Retention không bao giờ ghi learner_skill_state.
  2. historical_mastery chỉ tăng.
  3. last_exposure_at chỉ tiến, và luôn đi cùng lần cập nhật current_retention.
  4. Cùng một event_id không được áp dụng hai lần.
  5. historical_mastery < 0.6 → luôn groupLabel = null và urgency = NONE.
  6. Learner/parent không bao giờ nhận số model qua API.
  7. Retention thấp một mình không đủ để bắt ôn — phải có goal/prereq/kỳ thi.
  8. 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.