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 §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, 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ì
{
"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:
{
"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_jsoncủ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ìnhlearner_model_versionskhông giới hạn. - Phần bị trần cắt đếm được:
nodes_omittednằ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/gapsgiữ 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.
"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 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, 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)
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í.
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
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 và event publish.
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
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_planmissingCoreModels() 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 |
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
- Learner Model chỉ đọc, không bao giờ ghi
learner_skill_state. strengths/gapschỉ tính trên node cóevidence_count > 0.goals_summarylà tham chiếu kèmgoal_model_version— không sao chép goal.confidenceđo lượng bằng chứng, không đo học lực.- Version chỉ tăng khi
content_hashđổi. - Cập nhật model không bao giờ chặn hoặc làm hỏng đường làm bài.
- Bản chụp không được cắt im lặng: đã cắt thì phải đếm được (
nodes_omitted) — SRC-508. - 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 §2/§8/§15.
- Kiểm chứng: QG-005 (
models/service.test.ts). - Liên quan: Context Model · Goal · Readiness · Engines §1/§3 · Workflows.