---
url: https://docs.nemo12.com/reference/learner-model.md
description: >-
  Learner Model: bức ảnh tổng hợp học sinh đang ở đâu về học thuật, chụp theo
  thời điểm, xem lại được (SDD-002 §2).
---

# Learner Model

> "Học sinh này đang ở đâu về mặt học thuật?" — bức ảnh tổng hợp, chụp tại một thời điểm, có thể xem lại.

Thiết kế: [SDD-002](../architecture/sdd-002-learner-intelligence/models-and-engines.md) §2 · Code: `modules/models/service.ts` → `runLearnerModelEngine()` · Test: `models/service.test.ts` · Bảng: `learner_model_versions` (`model_kind='learner'`) + `learner_models` (bảng gốc 0003)

***

## 1. Learner Model **không phải** nơi lưu mastery

Đây là điều dễ hiểu sai nhất và cần nói ngay:

| | `learner_skill_state` | Learner Model |
| --- | --- | --- |
| Là gì | **Sự thật sống** — mastery/confidence từng node | **Bức ảnh tổng hợp** tại một thời điểm |
| Ai ghi | [Mastery Engine](engines.md#1-mastery--confidence-engine), mỗi câu trả lời | Learner Model Engine |
| Có version | ❌ | ✅ có hash, có lịch sử |
| Hỏi "con đang thế nào **bây giờ**" | ✅ đọc bảng này | ❌ |
| Hỏi "hôm đó hệ thống nghĩ gì về con" | ❌ | ✅ đọc version |

Learner Model **chỉ đọc, không bao giờ ghi** `learner_skill_state`. Nó là lớp **tổng hợp + đóng băng**: gom mastery từng node thành bức tranh theo môn, rồi lưu lại kèm hash để sau này đối chiếu.

Vì sao cần cả hai: khi phụ huynh hỏi *"tháng trước hệ thống bảo con ổn, sao giờ lại bảo yếu?"*, câu trả lời nằm ở việc so hai version — không nằm ở bảng trạng thái hiện tại (đã bị ghi đè từ lâu).

***

## 2. Model chứa gì

```jsonc
{
  "algorithm_version": "learner-v2",
  "computed_at": "2026-08-15T…",
  "confidence": 0.83,
  "profile": { "grade": 8, "current_school": "…", "province": "…" },
  "academic_state": {
    "nodes_tracked": 154,
    "subjects": [{
      "subject_id": "math",
      "nodes_total": 210,            // SRC-528: mẫu số coverage = CẢ graph, kể cả node chưa chạm
      "coverage": 0.2,               // nodes_assessed / nodes_total — coverage ≠ mastery
      "nodes_assessed": 42,          // chỉ node có evidence_count > 0
      "nodes_mastered": 18,          // mastery ≥ 0.8
      "avg_mastery": 0.61,
      "avg_confidence": 0.72,
      "trajectory": { "improving": 12, "stable": 25, "declining": 5 },
      "strengths": [ /* top 5 theo mastery */ ],
      "gaps":      [ /* bottom 5 theo mastery */ ],
      "nodes":     [ /* SRC-508: MỌI node có evidence_count > 0, xếp mastery giảm dần, trần 500 */ ],
      "nodes_omitted": 0,            // SRC-508: số node bị trần cắt — cắt thì phải đếm được
      "last_evidence_at": "…"
    }]
  },
  "evidence": {                     // SRC-528: State + Evidence
    "total": 318, "avg_reliability": 0.87,
    "first_evidence_at": "…", "last_evidence_at": "…",
    "distinct_sources": 3, "distinct_types": 4,
    "by_type": { "item_response": 290, "self_prediction": 28 }
  },
  "behavior": {                     // SRC-528: cách learner làm, không phải làm được bao nhiêu
    "sessions_total": 40, "sessions_completed": 33, "sessions_abandoned": 7,
    "completion_rate": 0.83, "responses_timed": 310, "avg_response_ms": 18700
  },
  "goals_summary": { "goal_model_version": 4, "total": 3, "empty": false, … },
  "confidence": 0.83
}
```

### Mỗi node mang theo chứng cớ của chính nó (SRC-508)

Câu hỏi 2026-08-21: *có cần thêm `evidence_count`, `trajectory`, `last_evidence_at` vào từng node
không?* **Đã thêm, và đang chạy** — cả ba trường có mặt trong `service.ts` (hàm dựng `node()`) và
được QG-005 canh bằng test `models/service.test.ts` ("mỗi node có evidence_count, trajectory và
last_evidence_at chứ không chỉ mastery").

Mỗi phần tử của `strengths` / `gaps` / `nodes` có **cùng một hình dạng**:

```jsonc
{
  "node_id": "…", "title": "…",
  "mastery": 0.4, "confidence": 0.62, "state": "gap",
  "evidence_count": 30,               // SRC-508
  "trajectory": "declining",          // SRC-508: improving | stable | declining | unknown
  "last_evidence_at": "2026-08-20T…"  // SRC-508
}
```

Lý do: `mastery: 0.4` một mình **không đọc được**. 0.4 sau 2 lần làm bài nghĩa là hệ chưa biết gì về
node ấy; 0.4 sau 30 lần và đang `declining` là một đứa trẻ đang tuột. Hai tình huống đòi hai phản
ứng khác hẳn nhau, mà bản chụp cũ (chỉ `mastery` + `confidence`) không phân biệt nổi.
`last_evidence_at` trả lời câu thứ ba: con số này đo hôm qua, hay đo ba tháng trước rồi nằm im.
Giá trị thiếu thì lấy mặc định rõ ràng (`evidence_count: 0`, `trajectory: "unknown"`,
`last_evidence_at: null`) chứ không bỏ trường — người đọc phải phân biệt được "không có" với "chưa
ghi".

### `nodes[]` và luật cắt-thì-phải-đếm (SRC-508)

Cùng đợt, bản chụp bỏ luôn thói quen chỉ giữ **5 strength + 5 gap** mỗi môn. Một learner có bằng
chứng ở 80 node chỉ còn 44 node trong hồ sơ, và **không dòng nào nói là đã bỏ bớt** — một bản ghi
sinh ra để đối chiếu về sau mà im lặng cắt thì hỏng đúng công dụng của nó.

* `nodes[]` giữ **mọi node có `evidence_count > 0`**, xếp mastery giảm dần.
* Trần **500 node mỗi môn** (`MAX_NODES_PER_SNAPSHOT`). Có trần vì `content_json` của mỗi version
  nằm trong **một dòng D1** còn số version thì tăng mãi; không trần thì learner học lâu năm làm
  phình `learner_model_versions` không giới hạn.
* Phần bị trần cắt **đếm được**: `nodes_omitted` nằm ngay trong chính bản chụp. Cắt im lặng thì bản
  chụp nói dối; cắt có ghi số thì nó vẫn trung thực.
* `strengths` / `gaps` **giữ nguyên 5 node**, không bỏ: chúng là hai khung ngắm và
  `recommendation.ts` đọc chúng. `nodes[]` là bổ sung, không phải thay thế.
* Node **chưa có bằng chứng** vẫn không được lọt vào `nodes[]` (bất biến §6.2) — thêm chúng là kéo
  dài mô hình bằng node chưa ai đo bao giờ.

### Vì sao evidence không chỉ là một con số tổng (SRC-528)

Learner Model là **State + Evidence**: mỗi claim phải trả lời được "vì sao tin". 300 evidence cùng
một loại practice yếu hơn hẳn 50 evidence trải trên diagnostic + practice + mastery_check — nên bản
chụp mang `distinct_sources`/`distinct_types`/`by_type` chứ không chỉ `total`.

**Freshness cố tình KHÔNG lưu dạng "số ngày"**: trường đó đổi theo đồng hồ chứ không theo learner —
mỗi lần engine chạy lại đẻ một version mới dù không có gì xảy ra, phá luật dedupe (§3.4). Người đọc
tự trừ từ `last_evidence_at`. Cùng lý do, `behavior.avg_response_ms` làm tròn về bậc 100ms.

`behavior` **quan sát, không phán xét**: bỏ dở nhiều là tín hiệu để Learning Engine đổi cách đưa bài
(bài ngắn hơn, thử thách nhỏ hơn), không phải một điểm số về learner.

### Ba điều dễ đọc sai

**1. `confidence` đo lượng bằng chứng, không đo học lực.**

```
confidence = 1 − 0.9^(số evidence)
```

| Số evidence | confidence |
| --- | --- |
| 0 | 0 |
| 10 | 0.65 |
| 30 | 0.96 |

Học sinh giỏi mới làm 3 câu vẫn có confidence 0.27. Khi confidence thấp, **mọi con số khác trong model đều là phỏng đoán** — đọc `avg_mastery` mà bỏ qua `confidence` là hiểu sai model.

Cùng công thức với mastery confidence (SDD-002 §8) nhưng cơ số khác (0.9 thay vì 0.55): model cấp learner cần **nhiều** bằng chứng hơn một node lẻ mới đáng tin.

**2. `strengths`/`gaps` chỉ tính trên node đã đo** (`evidence_count > 0`).

Node chưa đo **không bao giờ** bị gọi là "điểm yếu". Đây là luật đạo đức, không phải chi tiết kỹ thuật: gọi một đứa trẻ là yếu ở thứ chưa từng hỏi nó là vu oan.

Hệ quả: learner mới có `subjects: []` — không phải "yếu mọi thứ", mà là **chưa biết gì cả**.

**3. `goals_summary` là REF, không phải bản sao.**

```jsonc
"goals_summary": { "goal_model_version": 4, …summary của Goal Model }
```

Nhúng `goal_model_version` để truy ngược. **Không** copy `goals[]` vào đây — [Goal Model](goal-model.md) là nguồn sự thật duy nhất. Đây là lý do thứ tự trong WF-04 bắt buộc Goal chạy **trước** Learner.

***

## 3. Cơ chế CẬP NHẬT — hai đường, nặng nhẹ khác nhau

### 3.1 Đường đầy đủ: WF-04

[WF-04 step 3](workflows.md#4-step-thứ-tự-bắt-buộc), sau Goal → Context. Chạy khi: nộp bài chẩn đoán, nộp đề thi, khai/sửa mục tiêu, admin bấm tính lại.

### 3.2 Đường nhẹ: cập nhật theo **từng câu trả lời** (SRC-130)

```ts
updateLearnerModelInBackground(c, { learnerId, trigger: "EvidenceRecorded:practice", requestId });
```

Chỉ chạy **hai** engine: **Learner Model + Readiness** — hai model duy nhất đổi theo mastery. Goal/Context/Constraint không đổi vì một câu trả lời, chạy cả chuỗi mỗi câu là phí.

::: tip Vì sao móc vào từng câu chứ không đợi "nộp bài"
Yêu cầu của chủ dự án (SRC-130): Learner Model phải theo kịp learner ngay sau mỗi Experience — **và cả khi em bỏ dở giữa chừng**.

Bỏ dở nghĩa là **không bao giờ có sự kiện hoàn thành**. Nếu chỉ móc vào "nộp bài", một đứa trẻ làm 7 câu rồi đóng máy sẽ để lại 7 bằng chứng mà model không bao giờ nhìn thấy. Móc vào từng câu trả lời thì bằng chứng đã có là model đã biết.
:::

**Chạy nền, không chặn response**: gọi qua `c.executionCtx.waitUntil()`. Learner thấy kết quả câu vừa làm **ngay lập tức**, engine chạy sau lưng. Không có `executionCtx` (test, queue consumer) thì chạy thẳng.

Ba điểm móc, tất cả trong `modules/learning/routes.ts`:

| Trigger | Khi nào |
| --- | --- |
| `EvidenceRecorded:practice` | Trả lời một câu luyện tập |
| `EvidenceRecorded:self_prediction` | Khai khởi động đầu bài |
| `ExperienceCompleted:{status}` | Xong (hoặc trượt) một Experience |

### 3.3 Lỗi không bao giờ chặn learner

```ts
catch (e) { logEvent("error", "learner_model_update_degraded", { learner_id, trigger, request_id, … }); }
```

Engine nổ thì chỉ có một dòng log. **Học sinh vẫn làm bài bình thường.** Cùng nguyên tắc với [run log](workflows.md#1-hạ-tầng-run-log) và [event publish](queues.md#3-publish--không-được-làm-hỏng-nghiệp-vụ-chính).

Theo dõi `learner_model_update_degraded` trong Workers logs — tăng đột biến nghĩa là engine đang hỏng dù chưa ai kêu.

### 3.4 Version chỉ tăng khi nội dung đổi

Qua `saveModelVersion()`: `JSON.stringify → SHA-256 → so với version active`. Trả lời một câu mà kết quả tổng hợp không đổi (làm tròn 2 chữ số nuốt mất thay đổi nhỏ) → có dòng `engine_runs` nhưng **không** có version mới.

Đây là điều làm cho đường 3.2 an toàn: chạy engine sau **mỗi câu** không tạo ra hàng nghìn version rác.

### 3.5 Đồng bộ ngược về `learner_models`

```ts
if (saved.changed && saved.version > 0) { INSERT … ON CONFLICT DO UPDATE }
```

`learner_models` là bảng gốc từ migration 0003, có trước hệ thống version tổng quát. Giữ đồng bộ để code cũ không gãy. **Chỉ ghi khi `changed`** — không có version mới thì không có gì để đồng bộ.

***

## 4. Bảo đảm mọi learner đều có model (SRC-130)

`CORE_MODEL_KINDS` — **7 model** mà một learner đã dùng hệ thống phải có:

```
goal · context · learner · readiness · retention · constraint · learning_plan
```

`missingCoreModels()` tìm model thiếu; `ensureAllModels()` dựng bù, **đúng thứ tự phụ thuộc của WF-04**, và chỉ chạy engine của model còn thiếu.

> **"Chưa build" và "đã build, chưa có gì" là hai chuyện khác nhau** — admin phải phân biệt được. Vì vậy engine luôn sinh version **kể cả khi nội dung rỗng**, thay vì để trống trong admin và không ai biết là chưa chạy hay không có gì.

Tình huống thật gây ra nhu cầu này: learner có hoạt động **trước khi** engine được nối dây → không sự kiện nào từng chạy WF-04 cho em ấy. Cron dựng bù.

***

## 5. Cách DÙNG

| Nơi | Đọc gì |
| --- | --- |
| `GET /v1/learners/{id}/model-versions` | Danh sách version mọi model (admin/mentor) |
| `GET /v1/learners/{id}/model-versions/{version}` | Nội dung đầy đủ một version — REQ-INT-29 |
| `admin.nemo12.com` | Duyệt model theo learner, xem lịch sử |
| Planning Engine | Version mới nhất, cùng Readiness + Retention + Constraint |
| Lighthouse (`/v1/learners/{id}/lighthouse`) | Tổng hợp trạng thái |

::: warning Màn hình học **không** đọc Learner Model
Cockpit, Phòng Lab, tiến độ đều đọc thẳng `learner_skill_state` — cần số **hiện tại**, không cần bức ảnh.

Learner Model phục vụ ba việc khác: **xem lại lịch sử**, **giải thích quyết định**, và **cấp dữ liệu cho Planning**. Nhầm vai trò này sẽ dẫn tới việc hiển thị số cũ cho learner sau khi em vừa làm bài xong.
:::

***

## 6. Bất biến — vi phạm là bug

1. Learner Model **chỉ đọc**, không bao giờ ghi `learner_skill_state`.
2. `strengths`/`gaps` chỉ tính trên node có `evidence_count > 0`.
3. `goals_summary` là **tham chiếu** kèm `goal_model_version` — không sao chép goal.
4. `confidence` đo **lượng bằng chứng**, không đo học lực.
5. Version chỉ tăng khi `content_hash` đổi.
6. Cập nhật model **không bao giờ** chặn hoặc làm hỏng đường làm bài.
7. Bản chụp **không được cắt im lặng**: đã cắt thì phải đếm được (`nodes_omitted`) — SRC-508.
8. Chạy sau Goal và Context trong WF-04 — đảo thứ tự cho ra model tham chiếu version cũ.

## 7. Khoảng trống đã biết

| Việc | Trạng thái |
| --- | --- |
| `avg_mastery` là trung bình **không trọng số** — node quan trọng và node phụ tính ngang nhau | 🕓 muốn có trọng số thì phải gắn blueprint, mà thế là readiness |
| `strengths`/`gaps` cứng 5 node | ✅ vẫn cứng 5 **có chủ đích** (khung ngắm cho `recommendation.ts`); phần đầy đủ nằm ở `nodes[]` từ SRC-508 |
| Trajectory của cả model theo thời gian | 🕓 dựng được từ các version nhưng chưa có view |
| **Transfer** — làm được ở context mới không (SRC-528) | 🕓 cần item gắn nhãn "novel context" trước, chưa có nguồn đo thì không bịa |
| **Preferences quan sát được** — help-seeking, retry pattern (SRC-528) | 🕓 chờ event hành vi chi tiết hơn; hiện chỉ có `behavior` mức phiên |

## Trace

* REQ-INT-29 (model version inspectable).
* Nguồn: SRC-032, SRC-105, SRC-130 (cập nhật liên tục + đủ 7 model lõi), SRC-508 (chứng cớ từng node + `nodes[]`/`nodes_omitted`), SRC-528 (learner-v2: State + Evidence, coverage, behavior).
* Thiết kế: [SDD-002](../architecture/sdd-002-learner-intelligence/models-and-engines.md) §2/§8/§15.
* Kiểm chứng: QG-005 (`models/service.test.ts`).
* Liên quan: [Context Model](learner-context-model.md) · [Goal](goal-model.md) · [Readiness](readiness-model.md) · [Engines §1/§3](engines.md#3-learner-model-engine) · [Workflows](workflows.md).
