---
url: >-
  https://docs.nemo12.com/architecture/sdd-002-learner-intelligence/run-history-and-scheduling.md
description: >-
  Mỗi lượt chạy engine để lại bằng chứng gì, model tồn tại và cập nhật theo luật
  nào, engine chạy lúc nào và vì sao không chạy sau mỗi câu.
---

# SDD-002 · Bằng chứng thi hành, luật tồn tại model và lịch chạy engine

Một phần của [SDD-002](./index.md).

## 19. Bằng chứng thi hành — Run History & Model Version Store (REQ-PLT-13, REQ-INT-29, SRC-098)

Model và engine chỉ đáng tin khi **chứng minh được là đã chạy thật**. Ba thứ được ghi lại, tách bạch:

| Ghi cái gì | Bảng | Trả lời câu hỏi |
| --- | --- | --- |
| Workflow run + từng step | `workflow_runs`, `workflow_run_steps` | "WF-01/WF-03/WF-04 đã chạy chưa, bước nào hỏng?" |
| Mỗi lần một engine chạy | `engine_runs` | "Goal Engine có thật sự chạy lúc learner đổi ngày thi không?" |
| Snapshot nội dung model theo version | `learner_model_versions` | "Learner Model của em này lúc 14/08 ghi gì?" |

* **Engine chạy mà output không đổi vẫn có dòng `engine_runs`** — chỉ `learner_model_versions` mới dedupe theo `content_hash`. Hash tính trên **nội dung đã bỏ trường thời điểm** (`computed_at` — thời điểm đã nằm ở cột `generated_at`): để nguyên thì hash luôn khác, dedupe thành vô nghĩa và mỗi lần chạy lại đẻ một version. Hệ quả có chủ đích: các model mang trường theo ngày (`days_to_deadline`, `days_since_used`) dedupe ở mức **một version/ngày/learner** dù engine chạy bao nhiêu lần trong ngày. Nhờ vậy "engine đã chạy" và "model đã đổi" là hai câu hỏi riêng, không bị trộn.
* `learner_model_versions` generalize §15 cho cả 6 model: `{model_kind, version, algorithm_version, generated_at, evidence_cutoff, state, trigger, engine_run_id, workflow_run_id, content_json}`. Version cũ chuyển `state='superseded'`, **không xoá** — đọc lại được lịch sử theo thời gian.
* **Ghi log không được làm hỏng nghiệp vụ** (REQ-NFR-01): mọi lệnh ghi run/version bọc try/catch, hỏng thì `console.error` rồi đi tiếp; `runModelsSafely()` bọc cả chuỗi engine ở hot path (nộp bài, khai goal).
* Trigger đã nối dây: `LearnerModelInitialized` (WF-01) · `AssessmentCompleted` (WF-03 chẩn đoán, nộp đề) · `GoalChanged` / `EXAM_RESCHEDULED` (WF-15 khai mục tiêu, lịch thi) · `admin_recompute` (chạy tay từ admin) · `cron:0 21 * * *` (WF-17 Retention Refresh hằng ngày — [SDD-017 §15](../sdd-017-retention.md)). Practice từng câu chưa nối — evidence vẫn được ghi, model refresh ở lần completion/recompute kế tiếp.
* **Admin console** (tiếng Anh toàn bộ, REQ-PLT-14): trang **Models** đi 4 cấp `Learner list → Learner → Model → Version → Content`; trang **Runs** liệt kê workflow run (kèm step + engine chạy bên trong) và engine run. Mỗi cấp có URL riêng (`#/models/:learnerId/:kind/:versionId`). Cấp thứ 5 là so sánh — xem [§19.1](#_19-1-so-sanh-hai-version-lien-nhau-—-muoi-tieu-chi-co-dinh-req-int-38-src-530).

### 19.1 So sánh hai version liền nhau — mười tiêu chí cố định (REQ-INT-38, SRC-530)

Trang Version ở trên trả lời "bản này ghi gì". Câu người vận hành thật sự hỏi khi mở nó ra lại là câu khác: **"so với lần chạy trước thì đổi gì, và vì sao"**. Trả lời bằng cách mở hai tab JSON rồi dò mắt là việc người làm được nhưng làm sai.

`GET /v1/admin/model-versions/{versionId}/compare` so version đó với version **liền trước** theo **đúng mười tiêu chí, cố định cho mọi loại model**:

| # | Tiêu chí | Trả lời |
| --- | --- | --- |
| 1 | Sinh lúc nào | hai bản cách nhau bao lâu |
| 2 | Vì sao chạy | trigger + workflow |
| 3 | Bằng chứng tới đâu | `evidence_cutoff` — đầu vào thật |
| 4 | Thuật toán | `algorithm_version` |
| 5 | Sinh bằng gì | luật tất định hay qua AI Gateway |
| 6 | Độ tin của model | `confidence` + delta |
| 7 | Tóm tắt của model | từng trường của `summary` |
| 8 | Quy mô | số mục trong danh sách chính |
| 9 | Đổi ở mục nào | thêm / bớt / sửa từng mục |
| 10 | Vân tay nội dung | `content_hash`, kích thước, số đường dẫn lá khác nhau |

Bốn quyết định thiết kế, mỗi cái vá một cách nói dối khác nhau:

* **Mười tiêu chí CỐ ĐỊNH, không đổi theo loại model.** Bảng cố định thì lần đọc thứ hai đã quen mắt, và **thiếu vắng cũng là thông tin**: ô trống ở tiêu chí 6 nghĩa là model này không khai độ tin, chứ không phải màn hình quên hiện.
* **Tiêu chí 3+4 gánh phần nặng nhất.** Nếu nội dung đổi mà bằng chứng KHÔNG mới và thuật toán KHÔNG đổi, thì hoặc engine đọc một nguồn không ai khai, hoặc nó không tất định — vi phạm §20. Cả hai đều là lỗi, và cả hai đều vô hình nếu chỉ nhìn nội dung. Màn hình cảnh báo thẳng ở đúng dòng đó.
* **"Liền nhau" = version LỚN NHẤT còn nhỏ hơn, không phải `version - 1`.** Engine chỉ ghi version mới khi nội dung đổi (xem §19), nên dãy số **có lỗ** và trừ một là trỏ vào khoảng trống.
* **Danh sách chính của mỗi loại model khai tường minh** (Learner đọc tới cấp NODE, không dừng ở môn); loại chưa khai thì dò và **hiện ra đường dẫn đã dò được**. Dò rồi im lặng là cách nhanh nhất để màn hình so sánh nói dối: nó so một mảng phụ rồi báo "không có gì đổi".

Phép so đặt ở API (`modules/admin/modelCompare.ts`) chứ không ở màn hình, vì nó là một **phép đo có thể sai**: ở API thì test được bằng dữ liệu thật, viết trong React thì chỉ người nhìn mới biết đúng hay sai.

## 20. Luật tồn tại model & cập nhật liên tục (SRC-130, SRC-131)

Hai luật cứng, cao hơn mọi tối ưu:

**(1) Learner nào cũng có ĐỦ mọi model — nội dung rỗng vẫn phải tồn tại.** "Chưa build" và "đã build, chưa có gì" là hai câu trả lời khác nhau: cái đầu nói hệ chưa chạy, cái sau nói hệ đã nhìn và chưa có dữ liệu. Ô trống trong admin không phân biệt được hai điều đó. Vì vậy model rỗng phải nói rõ vì sao rỗng (`empty: true`, `declared: false`, kèm `note`), và:

* Tạo learner → `ensureAllModels()` chạy nền ngay, learner có đủ model từ giây đầu tiên.
* WF-01 (onboarding) và WF-04 (mỗi lần có bằng chứng) → chạy cả chuỗi 7 engine.
* WF-17 (đêm) → quét MỌI learner `status <> 'archived'` (mặc định của learners là `invited`, lọc `active` sẽ bỏ sót đúng em vừa được mời) và dựng bù model còn thiếu, chỉ chạy engine của model thiếu chứ không tính lại thứ đã có.
* Constraint Model có engine riêng (§/`modules/models/constraint.ts`) để không còn model nào "chưa có engine".

**(2) Learner Model bám sát learner theo từng bằng chứng, kể cả khi learner BỎ DỞ.** Chờ tới lúc "nộp bài" là sai: bỏ dở nghĩa là sự kiện hoàn thành không bao giờ tới, trong khi bằng chứng đã có. Nên móc vào từng bước:

| Lúc nào | Chạy gì |
| --- | --- |
| Trả lời từng câu practice | **Không dựng model.** Chỉ ghi evidence + mastery + retention (chi phí cố định) — xem §21 |
| Khởi động/self-prediction | như trên |
| Hoàn thành **hoặc trượt** một Learning/Assessment Experience | như trên (`ExperienceCompleted:completed\|failed`) |
| Nộp chẩn đoán / nộp đề / khai mục tiêu | cả chuỗi WF-04 (7 engine) |
| Hằng đêm | WF-17: retention + dựng bù model thiếu |

Đường theo-từng-câu cố tình nhẹ hơn WF-04 (chỉ 2 model đổi theo mastery) và chạy qua `waitUntil` — learner không phải chờ engine mới thấy kết quả câu vừa làm. Mỗi lượt vẫn để lại `engine_runs` làm bằng chứng (§19).

## 21. Chạy engine lúc nào — và vì sao KHÔNG chạy sau mỗi câu (SRC-138)

Bản đầu móc việc dựng lại Learner Model vào **từng câu trả lời**. Đúng về ý (model phải theo kịp
learner) nhưng sai về chi phí, vì hai việc này khác hẳn nhau:

| Việc | Chi phí | Chạy khi nào |
| --- | --- | --- |
| Ghi evidence + cập nhật mastery/retention của MỘT node | cố định, không phụ thuộc lịch sử | mỗi câu trả lời |
| Dựng lại Learner Model / Readiness | **đọc lại toàn bộ lịch sử** của learner | xem dưới |

Engine dựng model đọc `learner_skill_state` (mọi node) và tổng hợp `learner_evidence` (mọi bằng
chứng từng có). Chi phí một lần chạy ≈ *số evidence + số node*. Chạy sau mỗi câu nghĩa là một phiên
10 câu trả tiền 10 lần cho cùng một kết quả, và **giá mỗi câu tăng theo tổng số câu learner đã từng
làm** — học càng lâu càng đắt, đúng dạng chi phí không được để tồn tại trong hệ chạy nhiều năm.

Thêm một dấu hiệu lãng phí nhìn thấy ngay trong sổ chạy: Readiness của learner chưa có blueprint mục
tiêu chạy xong luôn báo `no change`.

**Chính sách hiện tại** — dựng lại khi có thứ mới THẬT SỰ, không phải khi có thao tác:

| Thời điểm | Vì sao chọn điểm này |
| --- | --- |
| Kết thúc một Experience (xong **hoặc trượt**) | Ranh giới tự nhiên: một lần chạy thay cho 6–10 lần |
| Learner quay lại (mở Phòng Lab, bắt đầu phiên mới) | Bắt đúng ca "bỏ dở lần trước" mà không cần sự kiện thoát |
| Nộp chẩn đoán / nộp đề / khai mục tiêu | Bằng chứng mạnh hoặc deadline đổi → chạy cả chuỗi WF-04 |
| Job đêm WF-17 | Chốt chặn cuối cho learner bỏ dở và không quay lại |

Ba điểm giữa dùng chung một phép kiểm rẻ — `learnerModelIsStale()`: so `MAX(last_evidence_at)` trong
`learner_skill_state` (bảng nhỏ, vài trăm dòng/learner) với `generated_at` của Learner Model mới
nhất. Không có gì mới thì **không chạy engine nào**.

Vì sao không dựa vào sự kiện "learner đã thoát": đóng tab, mất mạng, hết pin đều không phát ra sự
kiện nào. Suy ra từ trạng thái (model cũ hơn bằng chứng) thì luôn đúng, còn chờ sự kiện thì không.
