---
url: https://docs.nemo12.com/reference/retention-model.md
description: >-
  Retention Model và Retention Engine: cái em từng làm được bây giờ còn làm được
  không, đường ghi, đường đọc và refresh định kỳ.
---

# 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](../architecture/sdd-017-retention.md) · 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](#ai-gọi-nó--wf-17-retention-refresh) 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:

```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ì 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ủ ý"

```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.

::: warning 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 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](models.md#cơ-chế-versioning-dùng-chung): 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](learning-plan-model.md#hash-chỉ-tính-trên-phần-kế-hoạch): 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](schedules.md#wf-17--retention-refresh).

***

## 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

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](../architecture/sdd-017-retention.md); liên quan [SDD-002](../architecture/sdd-002-learner-intelligence/models-and-engines.md) §5/§7-8/§10/§18.
* Liên quan: [Engine Reference §6](engines.md#6-retention-engine) · [Models](models.md#6-retention-model-retention-v1) · [Schedules](schedules.md).
