Przeglądaj źródła

docs(词汇摸底): 新增后端能力设计和实施计划

金逸霄 20 godzin temu
rodzic
commit
778103e0e6

+ 3116 - 0
docs/superpowers/plans/2026-05-24-vocabulary-survey.md

@@ -0,0 +1,3116 @@
+# Vocabulary Survey Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build the mini-program vocabulary survey backend with grouped adaptive levels, MessagePack dictionary loading, in-memory single-node sessions, HTTP APIs, and tests.
+
+**Architecture:** Add a new `vocabulary-survey` ability that mirrors the existing Clean Architecture layout: `contracts`, `domain`, `application`, and `infrastructure`. Keep business rules in domain/application, keep MessagePack and in-memory technical implementations in infrastructure, and keep runtime limited to HTTP/controller/config wiring.
+
+**Tech Stack:** Java 17, Spring Boot 3.3.5, Maven multi-module, JUnit 5, AssertJ, MockMvc, ArchUnit, MessagePack Java (`org.msgpack:jackson-dataformat-msgpack` with `msgpack-core` fallback).
+
+---
+
+## Constraints for implementers
+
+- Do not commit unless the user explicitly authorizes commits. Use review checkpoints instead of `git commit` steps.
+- Do not print, copy into chat, or modify existing secret values. In particular, preserve existing `ability-center-runtime/src/main/resources/application-test.yml` secret-bearing lines without restating their values.
+- Copy only the authorized dictionary file from outside the workspace:
+  `F:\Codes\Projects\qingti.teaching.api\Qingti.Teaching.Dictionary\Resources\dict.data`.
+- Do not create database tables in this iteration.
+- Session recovery and idempotency only need to work inside the same service process.
+- Tests must not require real database credentials.
+
+---
+
+## File structure to create or modify
+
+### Root and runtime
+
+- Modify: `pom.xml` — add four new `vocabulary-survey` modules.
+- Modify: `ability-center-runtime/pom.xml` — depend on the new contracts/application/domain/infrastructure modules.
+- Create: `ability-center-runtime/src/main/resources/application-dev.yml` — dev datasource with blank credentials and vocabulary config.
+- Modify: `ability-center-runtime/src/main/resources/application-test.yml` — append datasource with blank credentials and vocabulary config while preserving existing report config.
+- Modify: `ability-center-runtime/src/main/resources/application.yml` — add default `ability.vocabulary-survey.dictionary.location`.
+- Create: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/configuration/VocabularySurveyRuntimeConfiguration.java` — bind vocabulary survey properties.
+- Create: `ability-center-runtime/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/adapter/http/VocabularySurveyController.java` — mini-program routes.
+- Create: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/adapter/http/VocabularySurveyControllerWebMvcTest.java` — web boundary tests.
+- Create: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/VocabularySurveyArchitectureTest.java` — architecture boundary tests.
+
+### Kernel
+
+- Modify: `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java` — add survey error codes.
+
+### Contracts module
+
+- Create: `abilities/vocabulary-survey/contracts/pom.xml`
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/StartVocabularySurveyRequest.java`
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/SubmitVocabularySurveyLevelRequest.java`
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyStatus.java`
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyWordResponse.java`
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyLevelResponse.java`
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyLearningRangeResponse.java`
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveyReportResponse.java`
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/VocabularySurveySessionResponse.java`
+- Create: `abilities/vocabulary-survey/contracts/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts/SubmitVocabularySurveyLevelResponse.java`
+
+### Domain module
+
+- Create: `abilities/vocabulary-survey/domain/pom.xml`
+- Create domain source files under `abilities/vocabulary-survey/domain/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/`:
+  - `VocabularyWord.java`
+  - `VocabularyWordProvider.java`
+  - `VocabularySurveyStatus.java`
+  - `VocabularySurveyGradePolicy.java`
+  - `VocabularySurveyIds.java`
+  - `VocabularySurveyWord.java`
+  - `VocabularySurveyLevel.java`
+  - `VocabularySurveyReport.java`
+  - `VocabularySurveySession.java`
+  - `VocabularySurveySessionRepository.java`
+  - `VocabularySurveyLevelPlanner.java`
+  - `VocabularySurveyKEstimator.java`
+  - `VocabularySurveyReportGenerator.java`
+- Create tests under `abilities/vocabulary-survey/domain/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/domain/`:
+  - `VocabularySurveyGradePolicyTest.java`
+  - `VocabularySurveyLevelPlannerTest.java`
+  - `VocabularySurveyKEstimatorTest.java`
+  - `VocabularySurveyReportGeneratorTest.java`
+  - `VocabularySurveySessionTest.java`
+
+### Application module
+
+- Create: `abilities/vocabulary-survey/application/pom.xml`
+- Create source files under `abilities/vocabulary-survey/application/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application/`:
+  - `VocabularySurveyProperties.java`
+  - `VocabularySurveyApplicationService.java`
+  - `StartVocabularySurveyCommand.java`
+  - `SubmitVocabularySurveyLevelCommand.java`
+  - `DefaultVocabularySurveyApplicationService.java`
+  - `VocabularySurveyResultMapper.java`
+- Create test: `abilities/vocabulary-survey/application/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/application/DefaultVocabularySurveyApplicationServiceTest.java`
+
+### Infrastructure module
+
+- Create: `abilities/vocabulary-survey/infrastructure/pom.xml`
+- Copy: `F:\Codes\Projects\qingti.teaching.api\Qingti.Teaching.Dictionary\Resources\dict.data` to `abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data`
+- Create source files under `abilities/vocabulary-survey/infrastructure/src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/`:
+  - `dictionary/DictionaryObject.java`
+  - `dictionary/DictionaryWord.java`
+  - `dictionary/DictionaryMeaning.java`
+  - `dictionary/DictionaryExchange.java`
+  - `dictionary/MessagePackDictionaryLoader.java`
+  - `dictionary/MessagePackVocabularyWordProvider.java`
+  - `repository/InMemoryVocabularySurveySessionRepository.java`
+- Create tests under `abilities/vocabulary-survey/infrastructure/src/test/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure/`:
+  - `dictionary/MessagePackVocabularyWordProviderTest.java`
+  - `repository/InMemoryVocabularySurveySessionRepositoryTest.java`
+
+---
+
+## Task 1: Add Maven modules and safe runtime configuration
+
+**Files:**
+- Modify: `pom.xml`
+- Modify: `ability-center-runtime/pom.xml`
+- Modify: `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java`
+- Create: `abilities/vocabulary-survey/contracts/pom.xml`
+- Create: `abilities/vocabulary-survey/domain/pom.xml`
+- Create: `abilities/vocabulary-survey/application/pom.xml`
+- Create: `abilities/vocabulary-survey/infrastructure/pom.xml`
+- Modify: `ability-center-runtime/src/main/resources/application.yml`
+- Create: `ability-center-runtime/src/main/resources/application-dev.yml`
+- Modify: `ability-center-runtime/src/main/resources/application-test.yml`
+
+- [ ] **Step 1: Run a module-targeted command to record the current failure**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain test
+```
+
+Expected: FAIL because the module path does not exist yet.
+
+- [ ] **Step 2: Add modules to the root Maven build**
+
+Modify `pom.xml` so the `<modules>` block becomes:
+
+```xml
+<modules>
+    <module>ability-center-runtime</module>
+    <module>ability-center-kernel</module>
+    <module>abilities/exam-sprint/contracts</module>
+    <module>abilities/exam-sprint/application</module>
+    <module>abilities/exam-sprint/domain</module>
+    <module>abilities/exam-sprint/infrastructure</module>
+    <module>abilities/vocabulary-survey/contracts</module>
+    <module>abilities/vocabulary-survey/domain</module>
+    <module>abilities/vocabulary-survey/application</module>
+    <module>abilities/vocabulary-survey/infrastructure</module>
+</modules>
+```
+
+- [ ] **Step 3: Create the contracts module POM**
+
+Create `abilities/vocabulary-survey/contracts/pom.xml`:
+
+```xml
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>dcjxb.microservice</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>vocabulary-survey-contracts</artifactId>
+    <name>vocabulary-survey-contracts</name>
+    <dependencies>
+        <dependency>
+            <groupId>jakarta.validation</groupId>
+            <artifactId>jakarta.validation-api</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+    </dependencies>
+</project>
+```
+
+- [ ] **Step 4: Create the domain module POM**
+
+Create `abilities/vocabulary-survey/domain/pom.xml`:
+
+```xml
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>dcjxb.microservice</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>vocabulary-survey-domain</artifactId>
+    <name>vocabulary-survey-domain</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>
+```
+
+- [ ] **Step 5: Create the application module POM**
+
+Create `abilities/vocabulary-survey/application/pom.xml`:
+
+```xml
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>dcjxb.microservice</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>vocabulary-survey-application</artifactId>
+    <name>vocabulary-survey-application</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-contracts</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-domain</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-application</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-context</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>
+```
+
+- [ ] **Step 6: Create the infrastructure module POM**
+
+Create `abilities/vocabulary-survey/infrastructure/pom.xml`:
+
+```xml
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>cn.yunzhixue</groupId>
+        <artifactId>dcjxb.microservice</artifactId>
+        <version>0.0.1-SNAPSHOT</version>
+        <relativePath>../../../pom.xml</relativePath>
+    </parent>
+    <artifactId>vocabulary-survey-infrastructure</artifactId>
+    <name>vocabulary-survey-infrastructure</name>
+    <dependencies>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>ability-center-kernel</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-domain</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>cn.yunzhixue</groupId>
+            <artifactId>vocabulary-survey-application</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-context</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-autoconfigure</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.msgpack</groupId>
+            <artifactId>jackson-dataformat-msgpack</artifactId>
+            <version>0.9.8</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-surefire-plugin</artifactId>
+                <configuration>
+                    <failIfNoSpecifiedTests>false</failIfNoSpecifiedTests>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>
+```
+
+- [ ] **Step 7: Wire runtime dependencies**
+
+Add these dependencies to `ability-center-runtime/pom.xml` after the existing `exam-sprint` dependencies:
+
+```xml
+<dependency>
+    <groupId>cn.yunzhixue</groupId>
+    <artifactId>vocabulary-survey-contracts</artifactId>
+    <version>${project.version}</version>
+</dependency>
+<dependency>
+    <groupId>cn.yunzhixue</groupId>
+    <artifactId>vocabulary-survey-application</artifactId>
+    <version>${project.version}</version>
+</dependency>
+<dependency>
+    <groupId>cn.yunzhixue</groupId>
+    <artifactId>vocabulary-survey-domain</artifactId>
+    <version>${project.version}</version>
+</dependency>
+<dependency>
+    <groupId>cn.yunzhixue</groupId>
+    <artifactId>vocabulary-survey-infrastructure</artifactId>
+    <version>${project.version}</version>
+</dependency>
+```
+
+- [ ] **Step 7a: Add survey error codes before domain code depends on them**
+
+Modify `ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java` by adding these enum entries before `INTERNAL_ERROR`:
+
+```java
+INVALID_GRADE("INVALID_GRADE", "invalid grade", 400),
+SURVEY_SESSION_NOT_FOUND("SURVEY_SESSION_NOT_FOUND", "survey session not found", 404),
+SURVEY_SESSION_EXPIRED("SURVEY_SESSION_EXPIRED", "survey session expired", 410),
+SURVEY_SESSION_FINISHED("SURVEY_SESSION_FINISHED", "survey session finished", 409),
+SURVEY_LEVEL_NOT_FOUND("SURVEY_LEVEL_NOT_FOUND", "survey level not found", 404),
+SURVEY_LEVEL_NOT_CURRENT("SURVEY_LEVEL_NOT_CURRENT", "survey level not current", 409),
+INVALID_SURVEY_WORD("INVALID_SURVEY_WORD", "invalid survey word", 400),
+SURVEY_WORD_GENERATION_FAILED("SURVEY_WORD_GENERATION_FAILED", "survey word generation failed", 500),
+SURVEY_REPORT_GENERATION_FAILED("SURVEY_REPORT_GENERATION_FAILED", "survey report generation failed", 500),
+```
+
+Ensure commas remain valid in the enum list. Domain tests in later tasks require `INVALID_GRADE` and `SURVEY_WORD_GENERATION_FAILED` to exist.
+
+- [ ] **Step 8: Add safe configuration**
+
+Append the vocabulary config to `ability-center-runtime/src/main/resources/application.yml` without removing the existing `ability.exam-sprint` section:
+
+```yaml
+  vocabulary-survey:
+    dictionary:
+      location: classpath:data/dict.data
+```
+
+Because `application.yml` already has a top-level `ability:` block, merge this snippet under that existing block instead of creating a second top-level `ability:` key.
+
+Create `ability-center-runtime/src/main/resources/application-dev.yml`:
+
+```yaml
+spring:
+  datasource:
+    url: jdbc:mysql://rm-uf6881jgyy065rdxdoo.rwlb.rds.aliyuncs.com:3306/qingti_db_dev?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
+    username:
+    password:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+
+ability:
+  vocabulary-survey:
+    dictionary:
+      location: classpath:data/dict.data
+```
+
+Append this `spring.datasource` block and `ability.vocabulary-survey` block to `ability-center-runtime/src/main/resources/application-test.yml`; do not edit or echo existing secret-bearing values:
+
+```yaml
+spring:
+  datasource:
+    url: jdbc:mysql://rm-uf6881jgyy065rdxdoo.rwlb.rds.aliyuncs.com:3306/qingti_test?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
+    username:
+    password:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+```
+
+Merge this under the existing top-level `ability:` block:
+
+```yaml
+  vocabulary-survey:
+    dictionary:
+      location: classpath:data/dict.data
+```
+
+- [ ] **Step 9: Verify module skeleton compiles far enough**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain test
+```
+
+Expected after only POM creation: PASS with no tests, or FAIL only because source files referenced in later tasks do not exist. If Maven says the module path is unknown, fix the root module paths before continuing.
+
+- [ ] **Step 10: Review checkpoint**
+
+Run:
+
+```powershell
+git diff -- pom.xml ability-center-runtime/pom.xml ability-center-kernel/src/main/java/cn/yunzhixue/ability/center/kernel/ErrorCode.java ability-center-runtime/src/main/resources/application.yml ability-center-runtime/src/main/resources/application-dev.yml ability-center-runtime/src/main/resources/application-test.yml
+```
+
+Expected: diff contains module and config additions only. No secret value should be newly introduced.
+
+---
+
+## Task 2: Add external contract DTOs
+
+**Files:**
+- Create all contract records listed in the file structure section.
+- Test through compile and runtime controller tests in later tasks.
+
+- [ ] **Step 1: Create request DTOs**
+
+Create `StartVocabularySurveyRequest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+
+public record StartVocabularySurveyRequest(
+        Integer studentId,
+        @NotNull @Min(3) @Max(12) Integer grade,
+        Boolean restart) {
+
+    public boolean restartRequested() {
+        return Boolean.TRUE.equals(restart);
+    }
+}
+```
+
+Create `SubmitVocabularySurveyLevelRequest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+public record SubmitVocabularySurveyLevelRequest(
+        @NotBlank String sessionId,
+        List<String> unknownSurveyWordIds) {
+
+    public List<String> unknownSurveyWordIdsOrEmpty() {
+        return unknownSurveyWordIds == null ? List.of() : List.copyOf(unknownSurveyWordIds);
+    }
+}
+```
+
+- [ ] **Step 2: Create status enum**
+
+Create `VocabularySurveyStatus.java` in contracts:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+import com.fasterxml.jackson.annotation.JsonValue;
+
+public enum VocabularySurveyStatus {
+    IN_PROGRESS("inProgress"),
+    FINISHED("finished"),
+    EXPIRED("expired"),
+    CANCELLED("cancelled");
+
+    private final String value;
+
+    VocabularySurveyStatus(String value) {
+        this.value = value;
+    }
+
+    @JsonValue
+    public String value() {
+        return value;
+    }
+}
+```
+
+- [ ] **Step 3: Create response records**
+
+Create `VocabularySurveyWordResponse.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record VocabularySurveyWordResponse(
+        String surveyWordId,
+        int wordId,
+        int meaningId,
+        String spell,
+        int wordFrequency) {
+}
+```
+
+Create `VocabularySurveyLevelResponse.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+import java.util.List;
+
+public record VocabularySurveyLevelResponse(
+        String levelId,
+        int levelNo,
+        int wordCount,
+        String feedback,
+        List<VocabularySurveyWordResponse> words) {
+}
+```
+
+Create `VocabularySurveyLearningRangeResponse.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record VocabularySurveyLearningRangeResponse(int min, int max) {
+}
+```
+
+Create `VocabularySurveyReportResponse.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record VocabularySurveyReportResponse(
+        int vocabularySize,
+        int k,
+        int grade,
+        int gradeMaxFrequency,
+        String abilityLevel,
+        VocabularySurveyLearningRangeResponse learningRange,
+        int testedWordCount,
+        int unknownWordCount,
+        String summary) {
+}
+```
+
+Create `VocabularySurveySessionResponse.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record VocabularySurveySessionResponse(
+        String sessionId,
+        VocabularySurveyStatus status,
+        int grade,
+        VocabularySurveyLevelResponse level,
+        VocabularySurveyLevelResponse currentLevel,
+        VocabularySurveyReportResponse report) {
+
+    public static VocabularySurveySessionResponse started(
+            String sessionId,
+            int grade,
+            VocabularySurveyLevelResponse level) {
+        return new VocabularySurveySessionResponse(
+                sessionId,
+                VocabularySurveyStatus.IN_PROGRESS,
+                grade,
+                level,
+                null,
+                null);
+    }
+
+    public static VocabularySurveySessionResponse current(
+            String sessionId,
+            int grade,
+            VocabularySurveyLevelResponse currentLevel) {
+        return new VocabularySurveySessionResponse(
+                sessionId,
+                VocabularySurveyStatus.IN_PROGRESS,
+                grade,
+                null,
+                currentLevel,
+                null);
+    }
+
+    public static VocabularySurveySessionResponse finished(
+            String sessionId,
+            int grade,
+            VocabularySurveyReportResponse report) {
+        return new VocabularySurveySessionResponse(
+                sessionId,
+                VocabularySurveyStatus.FINISHED,
+                grade,
+                null,
+                null,
+                report);
+    }
+}
+```
+
+Create `SubmitVocabularySurveyLevelResponse.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.contracts;
+
+public record SubmitVocabularySurveyLevelResponse(
+        String sessionId,
+        VocabularySurveyStatus status,
+        int submittedLevelNo,
+        VocabularySurveyLevelResponse level,
+        VocabularySurveyReportResponse report) {
+
+    public static SubmitVocabularySurveyLevelResponse nextLevel(
+            String sessionId,
+            int submittedLevelNo,
+            VocabularySurveyLevelResponse level) {
+        return new SubmitVocabularySurveyLevelResponse(
+                sessionId,
+                VocabularySurveyStatus.IN_PROGRESS,
+                submittedLevelNo,
+                level,
+                null);
+    }
+
+    public static SubmitVocabularySurveyLevelResponse finished(
+            String sessionId,
+            int submittedLevelNo,
+            VocabularySurveyReportResponse report) {
+        return new SubmitVocabularySurveyLevelResponse(
+                sessionId,
+                VocabularySurveyStatus.FINISHED,
+                submittedLevelNo,
+                null,
+                report);
+    }
+}
+```
+
+- [ ] **Step 4: Compile contracts**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/contracts test
+```
+
+Expected: PASS.
+
+---
+
+## Task 3: Add domain grade policy, value types, and level planning
+
+**Files:**
+- Create: domain source files for `VocabularyWord`, `VocabularyWordProvider`, `VocabularySurveyStatus`, `VocabularySurveyGradePolicy`, `VocabularySurveyIds`, `VocabularySurveyWord`, `VocabularySurveyLevel`, `VocabularySurveyLevelPlanner`.
+- Test: `VocabularySurveyGradePolicyTest.java`, `VocabularySurveyLevelPlannerTest.java`.
+
+- [ ] **Step 1: Write grade policy tests**
+
+Create `VocabularySurveyGradePolicyTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class VocabularySurveyGradePolicyTest {
+
+    @Test
+    void gradeMaxFrequencyReturnsConfiguredFrequency() {
+        assertThat(VocabularySurveyGradePolicy.gradeMaxFrequency(3)).isEqualTo(400);
+        assertThat(VocabularySurveyGradePolicy.gradeMaxFrequency(7)).isEqualTo(1600);
+        assertThat(VocabularySurveyGradePolicy.gradeMaxFrequency(12)).isEqualTo(4800);
+    }
+
+    @Test
+    void invalidGradeThrowsBusinessException() {
+        assertThatThrownBy(() -> VocabularySurveyGradePolicy.gradeMaxFrequency(2))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("invalid grade");
+    }
+
+    @Test
+    void challengeMaxFrequencyUsesNextGradeAndDictionaryMaximum() {
+        assertThat(VocabularySurveyGradePolicy.challengeMaxFrequency(7, 5000)).isEqualTo(2000);
+        assertThat(VocabularySurveyGradePolicy.challengeMaxFrequency(12, 4200)).isEqualTo(4200);
+    }
+}
+```
+
+- [ ] **Step 2: Run grade policy tests and verify failure**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyGradePolicyTest test
+```
+
+Expected: FAIL because `VocabularySurveyGradePolicy` does not exist.
+
+- [ ] **Step 3: Implement grade policy and core value types**
+
+Create `VocabularyWord.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+public record VocabularyWord(int wordId, int meaningId, String spell, int wordFrequency) {
+    public VocabularyWord {
+        if (wordId <= 0) {
+            throw new IllegalArgumentException("wordId must be positive");
+        }
+        if (meaningId <= 0) {
+            throw new IllegalArgumentException("meaningId must be positive");
+        }
+        if (spell == null || spell.isBlank()) {
+            throw new IllegalArgumentException("spell must not be blank");
+        }
+        if (wordFrequency <= 0) {
+            throw new IllegalArgumentException("wordFrequency must be positive");
+        }
+    }
+}
+```
+
+Create `VocabularyWordProvider.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public interface VocabularyWordProvider {
+
+    List<VocabularyWord> findWordsAroundFrequency(
+            int centerFrequency,
+            int width,
+            int count,
+            Set<Integer> excludedWordIds);
+
+    Optional<VocabularyWord> findByWordId(int wordId);
+
+    Optional<VocabularyWord> findByMeaningId(int meaningId);
+    int maxWordFrequency();
+}
+```
+
+Create `VocabularySurveyStatus.java` in domain:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+public enum VocabularySurveyStatus {
+    IN_PROGRESS,
+    FINISHED,
+    EXPIRED,
+    CANCELLED
+}
+```
+
+Create `VocabularySurveyGradePolicy.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+
+import java.util.Map;
+
+public final class VocabularySurveyGradePolicy {
+
+    private static final Map<Integer, Integer> GRADE_MAX_FREQUENCIES = Map.ofEntries(
+            Map.entry(3, 400),
+            Map.entry(4, 700),
+            Map.entry(5, 1000),
+            Map.entry(6, 1200),
+            Map.entry(7, 1600),
+            Map.entry(8, 2000),
+            Map.entry(9, 2400),
+            Map.entry(10, 3200),
+            Map.entry(11, 4000),
+            Map.entry(12, 4800));
+
+    private VocabularySurveyGradePolicy() {
+    }
+
+    public static int gradeMaxFrequency(int grade) {
+        Integer frequency = GRADE_MAX_FREQUENCIES.get(grade);
+        if (frequency == null) {
+            throw new BusinessException(ErrorCode.INVALID_GRADE, "invalid grade: " + grade);
+        }
+        return frequency;
+    }
+
+    public static int challengeMaxFrequency(int grade, int dictionaryMaxFrequency) {
+        int nextGrade = Math.min(12, grade + 1);
+        return Math.min(gradeMaxFrequency(nextGrade), dictionaryMaxFrequency);
+    }
+}
+```
+
+Create `VocabularySurveyIds.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.util.UUID;
+
+public final class VocabularySurveyIds {
+
+    private VocabularySurveyIds() {
+    }
+
+    public static String sessionId() {
+        return "surv_" + compactUuid();
+    }
+
+    public static String levelId() {
+        return "lvl_" + compactUuid();
+    }
+
+    public static String surveyWordId() {
+        return "sw_" + compactUuid();
+    }
+
+    private static String compactUuid() {
+        return UUID.randomUUID().toString().replace("-", "");
+    }
+}
+```
+
+- [ ] **Step 4: Run grade policy tests and verify pass**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyGradePolicyTest test
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Write level planner tests**
+
+Create `VocabularySurveyLevelPlannerTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import org.junit.jupiter.api.Test;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class VocabularySurveyLevelPlannerTest {
+
+    @Test
+    void generatedLevelContainsTwelveWordsAndNoRepeatedWordId() {
+        VocabularySurveyLevelPlanner planner = new VocabularySurveyLevelPlanner(new FakeWordProvider(200));
+
+        VocabularySurveyLevel level = planner.generateLevel(1, 560, 1600, Set.of());
+
+        assertThat(level.words()).hasSize(12);
+        assertThat(level.words().stream().map(VocabularySurveyWord::wordId)).doesNotHaveDuplicates();
+    }
+
+    @Test
+    void generatedLevelExcludesAlreadyTestedWords() {
+        FakeWordProvider provider = new FakeWordProvider(200);
+        VocabularySurveyLevelPlanner planner = new VocabularySurveyLevelPlanner(provider);
+        Set<Integer> excluded = new HashSet<>();
+        for (int wordId = 1; wordId <= 20; wordId++) {
+            excluded.add(wordId);
+        }
+
+        VocabularySurveyLevel level = planner.generateLevel(1, 120, 200, excluded);
+
+        assertThat(level.words()).allMatch(word -> !excluded.contains(word.wordId()));
+    }
+
+    @Test
+    void generationFailureThrowsBusinessException() {
+        VocabularySurveyLevelPlanner planner = new VocabularySurveyLevelPlanner(new FakeWordProvider(8));
+
+        assertThatThrownBy(() -> planner.generateLevel(1, 100, 100, Set.of()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("failed to generate survey words");
+    }
+
+    private static final class FakeWordProvider implements VocabularyWordProvider {
+        private final List<VocabularyWord> words = new ArrayList<>();
+
+        private FakeWordProvider(int count) {
+            for (int index = 1; index <= count; index++) {
+                words.add(new VocabularyWord(index, 1000 + index, "word" + index, index * 10));
+            }
+        }
+
+        @Override
+        public List<VocabularyWord> findWordsAroundFrequency(
+                int centerFrequency,
+                int width,
+                int count,
+                Set<Integer> excludedWordIds) {
+            return words.stream()
+                    .filter(word -> !excludedWordIds.contains(word.wordId()))
+                    .sorted((left, right) -> Integer.compare(
+                            Math.abs(left.wordFrequency() - centerFrequency),
+                            Math.abs(right.wordFrequency() - centerFrequency)))
+                    .limit(count)
+                    .toList();
+        }
+
+        @Override
+        public Optional<VocabularyWord> findByWordId(int wordId) {
+            return words.stream().filter(word -> word.wordId() == wordId).findFirst();
+        }
+
+        @Override
+        public Optional<VocabularyWord> findByMeaningId(int meaningId) {
+            return words.stream().filter(word -> word.meaningId() == meaningId).findFirst();
+        }
+
+        @Override
+        public int maxWordFrequency() {
+            return words.stream().mapToInt(VocabularyWord::wordFrequency).max().orElse(0);
+        }
+    }
+}
+```
+
+- [ ] **Step 6: Run level planner tests and verify failure**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyLevelPlannerTest test
+```
+
+Expected: FAIL because `VocabularySurveyLevelPlanner`, `VocabularySurveyLevel`, and `VocabularySurveyWord` do not exist.
+
+- [ ] **Step 7: Implement level and word models**
+
+Create `VocabularySurveyWord.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.time.Instant;
+
+public record VocabularySurveyWord(
+        String surveyWordId,
+        String sessionId,
+        String levelId,
+        int levelNo,
+        int wordId,
+        int meaningId,
+        String spell,
+        int wordFrequency,
+        Boolean unknown,
+        Instant createdAt,
+        Instant submittedAt) {
+
+    public static VocabularySurveyWord unsubmitted(
+            String sessionId,
+            String levelId,
+            int levelNo,
+            VocabularyWord word,
+            Instant createdAt) {
+        return new VocabularySurveyWord(
+                VocabularySurveyIds.surveyWordId(),
+                sessionId,
+                levelId,
+                levelNo,
+                word.wordId(),
+                word.meaningId(),
+                word.spell(),
+                word.wordFrequency(),
+                null,
+                createdAt,
+                null);
+    }
+
+    public VocabularySurveyWord submitted(boolean isUnknown, Instant submittedAt) {
+        return new VocabularySurveyWord(
+                surveyWordId,
+                sessionId,
+                levelId,
+                levelNo,
+                wordId,
+                meaningId,
+                spell,
+                wordFrequency,
+                isUnknown,
+                createdAt,
+                submittedAt);
+    }
+}
+```
+
+Create `VocabularySurveyLevel.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+
+public record VocabularySurveyLevel(
+        String levelId,
+        String sessionId,
+        int levelNo,
+        int centerFrequency,
+        int wordWidth,
+        List<VocabularySurveyWord> words,
+        Double unknownRate,
+        String feedback,
+        Instant createdAt,
+        Instant submittedAt) {
+
+    public int wordCount() {
+        return words.size();
+    }
+
+    public boolean submitted() {
+        return submittedAt != null;
+    }
+
+    public boolean nearBoundary() {
+        return unknownRate != null && unknownRate > 0.4D && unknownRate < 0.6D;
+    }
+
+    public VocabularySurveyLevel submitted(Set<String> unknownSurveyWordIds, Instant submittedAt) {
+        List<VocabularySurveyWord> submittedWords = words.stream()
+                .map(word -> word.submitted(unknownSurveyWordIds.contains(word.surveyWordId()), submittedAt))
+                .toList();
+        long unknownCount = submittedWords.stream().filter(word -> Boolean.TRUE.equals(word.unknown())).count();
+        double submittedUnknownRate = submittedWords.isEmpty() ? 0.0D : (double) unknownCount / submittedWords.size();
+        return new VocabularySurveyLevel(
+                levelId,
+                sessionId,
+                levelNo,
+                centerFrequency,
+                wordWidth,
+                submittedWords,
+                submittedUnknownRate,
+                feedback,
+                createdAt,
+                submittedAt);
+    }
+}
+```
+
+- [ ] **Step 8: Implement level planner**
+
+Create `VocabularySurveyLevelPlanner.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class VocabularySurveyLevelPlanner {
+
+    public static final int WORDS_PER_LEVEL = 12;
+    public static final int MIN_WORD_WIDTH = 100;
+
+    private final VocabularyWordProvider wordProvider;
+
+    public VocabularySurveyLevelPlanner(VocabularyWordProvider wordProvider) {
+        this.wordProvider = wordProvider;
+    }
+
+    public VocabularySurveyLevel generateLevel(
+            int levelNo,
+            int centerFrequency,
+            int wordWidth,
+            Set<Integer> testedWordIds) {
+        return generateLevel("surv_preview", levelNo, centerFrequency, wordWidth, testedWordIds, Instant.EPOCH);
+    }
+
+    public VocabularySurveyLevel generateLevel(
+            String sessionId,
+            int levelNo,
+            int centerFrequency,
+            int wordWidth,
+            Set<Integer> testedWordIds,
+            Instant now) {
+        String levelId = VocabularySurveyIds.levelId();
+        Set<Integer> excluded = new HashSet<>(testedWordIds);
+        List<VocabularySurveyWord> surveyWords = new ArrayList<>();
+        for (int targetFrequency : sampleFrequencies(centerFrequency, wordWidth, WORDS_PER_LEVEL)) {
+            List<VocabularyWord> candidates = wordProvider.findWordsAroundFrequency(
+                    targetFrequency,
+                    Math.max(wordWidth / WORDS_PER_LEVEL, MIN_WORD_WIDTH),
+                    WORDS_PER_LEVEL,
+                    excluded);
+            if (!candidates.isEmpty()) {
+                VocabularyWord selected = candidates.get(0);
+                excluded.add(selected.wordId());
+                surveyWords.add(VocabularySurveyWord.unsubmitted(sessionId, levelId, levelNo, selected, now));
+            }
+        }
+        if (surveyWords.size() < WORDS_PER_LEVEL) {
+            List<VocabularyWord> backfill = wordProvider.findWordsAroundFrequency(
+                    centerFrequency,
+                    Math.max(wordWidth, MIN_WORD_WIDTH),
+                    WORDS_PER_LEVEL - surveyWords.size(),
+                    excluded);
+            for (VocabularyWord word : backfill) {
+                excluded.add(word.wordId());
+                surveyWords.add(VocabularySurveyWord.unsubmitted(sessionId, levelId, levelNo, word, now));
+            }
+        }
+        if (surveyWords.size() != WORDS_PER_LEVEL) {
+            throw new BusinessException(
+                    ErrorCode.SURVEY_WORD_GENERATION_FAILED,
+                    "failed to generate survey words for level " + levelNo);
+        }
+        return new VocabularySurveyLevel(
+                levelId,
+                sessionId,
+                levelNo,
+                centerFrequency,
+                wordWidth,
+                List.copyOf(surveyWords),
+                null,
+                feedbackFor(levelNo),
+                now,
+                null);
+    }
+
+    public NextLevelState nextState(int currentCenterFrequency, int currentWordWidth, double unknownRate, int challengeMaxFrequency) {
+        int nextWordWidth = Math.max((int) Math.round(currentWordWidth * 0.75D), MIN_WORD_WIDTH);
+        int offset = Math.max(1, nextWordWidth / 6);
+        int nextCenter = currentCenterFrequency;
+        if (unknownRate <= 0.4D) {
+            nextCenter = currentCenterFrequency + offset;
+        } else if (unknownRate >= 0.6D) {
+            nextCenter = currentCenterFrequency - offset;
+        }
+        nextCenter = Math.max(100, Math.min(challengeMaxFrequency, nextCenter));
+        return new NextLevelState(nextCenter, nextWordWidth);
+    }
+
+    private List<Integer> sampleFrequencies(int centerFrequency, int wordWidth, int count) {
+        int left = Math.max(1, centerFrequency - wordWidth / 2);
+        int right = Math.max(left, centerFrequency + wordWidth / 2);
+        if (count == 1) {
+            return List.of(centerFrequency);
+        }
+        List<Integer> samples = new ArrayList<>();
+        double step = (double) (right - left) / (count - 1);
+        for (int index = 0; index < count; index++) {
+            samples.add((int) Math.round(left + step * index));
+        }
+        return samples;
+    }
+
+    private String feedbackFor(int levelNo) {
+        if (levelNo == 1) {
+            return "先来热个身,选出你不认识的单词吧";
+        }
+        return "继续挑战,选出你不认识的单词吧";
+    }
+
+    public record NextLevelState(int centerFrequency, int wordWidth) {
+    }
+}
+```
+
+- [ ] **Step 9: Run level planner tests and verify pass**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyLevelPlannerTest test
+```
+
+Expected: PASS.
+
+---
+
+## Task 4: Add domain K estimation, report generation, and session aggregate
+
+**Files:**
+- Create: `VocabularySurveyReport.java`, `VocabularySurveyKEstimator.java`, `VocabularySurveyReportGenerator.java`, `VocabularySurveySession.java`, `VocabularySurveySessionRepository.java`.
+- Test: `VocabularySurveyKEstimatorTest.java`, `VocabularySurveyReportGeneratorTest.java`, `VocabularySurveySessionTest.java`.
+
+- [ ] **Step 1: Write K estimator test**
+
+Create `VocabularySurveyKEstimatorTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class VocabularySurveyKEstimatorTest {
+
+    @Test
+    void estimateKMinimizesUnknownLeftAndKnownRightError() {
+        VocabularySurveyLevel level = submittedLevel(List.of(
+                word(1, 100, false),
+                word(2, 200, false),
+                word(3, 300, true),
+                word(4, 400, true)),
+                250);
+
+        int k = new VocabularySurveyKEstimator().estimateK(List.of(level), 250);
+
+        assertThat(k).isEqualTo(200);
+    }
+
+    private VocabularySurveyLevel submittedLevel(List<VocabularySurveyWord> words, int centerFrequency) {
+        VocabularySurveyLevel level = new VocabularySurveyLevel(
+                "lvl_test",
+                "surv_test",
+                1,
+                centerFrequency,
+                400,
+                words,
+                null,
+                "feedback",
+                Instant.EPOCH,
+                null);
+        return level.submitted(Set.of("sw_3", "sw_4"), Instant.EPOCH.plusSeconds(1));
+    }
+
+    private VocabularySurveyWord word(int wordId, int frequency, boolean unknown) {
+        String surveyWordId = "sw_" + wordId;
+        return new VocabularySurveyWord(
+                surveyWordId,
+                "surv_test",
+                "lvl_test",
+                1,
+                wordId,
+                1000 + wordId,
+                "word" + wordId,
+                frequency,
+                unknown,
+                Instant.EPOCH,
+                Instant.EPOCH.plusSeconds(1));
+    }
+}
+```
+
+- [ ] **Step 2: Implement K estimator**
+
+Create `VocabularySurveyKEstimator.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.util.Comparator;
+import java.util.List;
+
+public class VocabularySurveyKEstimator {
+
+    public int estimateK(List<VocabularySurveyLevel> levels, int preferredFrequency) {
+        List<VocabularySurveyWord> testedWords = levels.stream()
+                .flatMap(level -> level.words().stream())
+                .filter(word -> word.unknown() != null)
+                .sorted(Comparator.comparingInt(VocabularySurveyWord::wordFrequency))
+                .toList();
+        if (testedWords.isEmpty()) {
+            return Math.max(1, preferredFrequency);
+        }
+        return testedWords.stream()
+                .map(VocabularySurveyWord::wordFrequency)
+                .distinct()
+                .min(Comparator
+                        .comparingInt((Integer candidate) -> error(candidate, testedWords))
+                        .thenComparingInt(candidate -> Math.abs(candidate - preferredFrequency)))
+                .orElse(preferredFrequency);
+    }
+
+    private int error(int candidateK, List<VocabularySurveyWord> testedWords) {
+        int error = 0;
+        for (VocabularySurveyWord word : testedWords) {
+            boolean unknown = Boolean.TRUE.equals(word.unknown());
+            if (word.wordFrequency() <= candidateK && unknown) {
+                error++;
+            }
+            if (word.wordFrequency() > candidateK && !unknown) {
+                error++;
+            }
+        }
+        return error;
+    }
+}
+```
+
+- [ ] **Step 3: Write report generator test**
+
+Create `VocabularySurveyReportGeneratorTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class VocabularySurveyReportGeneratorTest {
+
+    @Test
+    void reportUsesKAsVocabularySizeAndMapsAbilityLevel() {
+        VocabularySurveyReport report = new VocabularySurveyReportGenerator()
+                .generate(7, 1600, 2000, 1500, List.of(levelWithWords()));
+
+        assertThat(report.vocabularySize()).isEqualTo(1500);
+        assertThat(report.k()).isEqualTo(1500);
+        assertThat(report.abilityLevel()).isEqualTo("年级稳定");
+        assertThat(report.learningRange().min()).isEqualTo(1260);
+        assertThat(report.learningRange().max()).isEqualTo(1740);
+        assertThat(report.testedWordCount()).isEqualTo(2);
+        assertThat(report.unknownWordCount()).isEqualTo(1);
+    }
+
+    private VocabularySurveyLevel levelWithWords() {
+        return new VocabularySurveyLevel(
+                "lvl_test",
+                "surv_test",
+                1,
+                1500,
+                100,
+                List.of(
+                        submittedWord("sw_1", 1, false),
+                        submittedWord("sw_2", 2, true)),
+                0.5D,
+                "feedback",
+                Instant.EPOCH,
+                Instant.EPOCH.plusSeconds(1));
+    }
+
+    private VocabularySurveyWord submittedWord(String surveyWordId, int wordId, boolean unknown) {
+        return new VocabularySurveyWord(
+                surveyWordId,
+                "surv_test",
+                "lvl_test",
+                1,
+                wordId,
+                1000 + wordId,
+                "word" + wordId,
+                wordId * 100,
+                unknown,
+                Instant.EPOCH,
+                Instant.EPOCH.plusSeconds(1));
+    }
+}
+```
+
+- [ ] **Step 4: Implement report model and generator**
+
+Create `VocabularySurveyReport.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+public record VocabularySurveyReport(
+        int vocabularySize,
+        int k,
+        int grade,
+        int gradeMaxFrequency,
+        String abilityLevel,
+        LearningRange learningRange,
+        int testedWordCount,
+        int unknownWordCount,
+        String summary) {
+
+    public record LearningRange(int min, int max) {
+    }
+}
+```
+
+Create `VocabularySurveyReportGenerator.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.util.List;
+
+public class VocabularySurveyReportGenerator {
+
+    public VocabularySurveyReport generate(
+            int grade,
+            int gradeMaxFrequency,
+            int challengeMaxFrequency,
+            int k,
+            List<VocabularySurveyLevel> levels) {
+        int testedWordCount = (int) levels.stream()
+                .flatMap(level -> level.words().stream())
+                .filter(word -> word.unknown() != null)
+                .count();
+        int unknownWordCount = (int) levels.stream()
+                .flatMap(level -> level.words().stream())
+                .filter(word -> Boolean.TRUE.equals(word.unknown()))
+                .count();
+        String abilityLevel = abilityLevel(k, gradeMaxFrequency);
+        int rangeOffset = (int) Math.round(0.15D * gradeMaxFrequency);
+        int min = Math.max(1, k - rangeOffset);
+        int max = Math.min(challengeMaxFrequency, k + rangeOffset);
+        return new VocabularySurveyReport(
+                k,
+                k,
+                grade,
+                gradeMaxFrequency,
+                abilityLevel,
+                new VocabularySurveyReport.LearningRange(min, max),
+                testedWordCount,
+                unknownWordCount,
+                summary(abilityLevel));
+    }
+
+    private String abilityLevel(int k, int gradeMaxFrequency) {
+        double ratio = (double) k / gradeMaxFrequency;
+        if (ratio < 0.45D) {
+            return "基础待加强";
+        }
+        if (ratio < 0.75D) {
+            return "年级基础";
+        }
+        if (ratio < 1.05D) {
+            return "年级稳定";
+        }
+        if (ratio <= 1.25D) {
+            return "超前挑战";
+        }
+        return "明显超前";
+    }
+
+    private String summary(String abilityLevel) {
+        return switch (abilityLevel) {
+            case "基础待加强" -> "建议先巩固本年级核心高频词,再逐步挑战更高频段。";
+            case "年级基础" -> "你的词汇基础已具备雏形,建议继续巩固本年级常见词。";
+            case "年级稳定" -> "你的词汇基础比较稳定,适合继续挑战本年级中高阶词汇。";
+            case "超前挑战" -> "你的词汇能力已经超过当前年级基础要求,可以适当挑战更高频段词汇。";
+            default -> "你的词汇掌握明显超前,可以尝试挑战更高年级词汇。";
+        };
+    }
+}
+```
+
+- [ ] **Step 4a: Run K estimator and report generator tests**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain -Dtest=VocabularySurveyKEstimatorTest,VocabularySurveyReportGeneratorTest test
+```
+
+Expected: PASS.
+
+- [ ] **Step 5: Write session tests**
+
+Create `VocabularySurveySessionTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class VocabularySurveySessionTest {
+
+    @Test
+    void stopAfterFourLevelsWhenRecentTwoLevelsAreNearBoundary() {
+        VocabularySurveySession session = newSessionWithSubmittedRates(0.1D, 0.2D, 0.5D, 0.5D);
+
+        assertThat(session.shouldFinish()).isTrue();
+    }
+
+    @Test
+    void stopAfterDefaultFiveLevels() {
+        VocabularySurveySession session = newSessionWithSubmittedRates(0.1D, 0.2D, 0.1D, 0.2D, 0.1D);
+
+        assertThat(session.shouldFinish()).isTrue();
+    }
+
+    @Test
+    void continueBeforeFourLevels() {
+        VocabularySurveySession session = newSessionWithSubmittedRates(0.5D, 0.5D, 0.5D);
+
+        assertThat(session.shouldFinish()).isFalse();
+    }
+
+    private VocabularySurveySession newSessionWithSubmittedRates(double... rates) {
+        VocabularySurveySession session = VocabularySurveySession.start(
+                "surv_test",
+                123,
+                7,
+                1600,
+                2000,
+                560,
+                1600,
+                Instant.EPOCH,
+                Instant.EPOCH.plusSeconds(86400));
+        for (int index = 0; index < rates.length; index++) {
+            session.addLevel(level(index + 1, rates[index]));
+        }
+        return session;
+    }
+
+    private VocabularySurveyLevel level(int levelNo, double unknownRate) {
+        return new VocabularySurveyLevel(
+                "lvl_" + levelNo,
+                "surv_test",
+                levelNo,
+                500,
+                100,
+                List.of(),
+                unknownRate,
+                "feedback",
+                Instant.EPOCH,
+                Instant.EPOCH.plusSeconds(levelNo));
+    }
+}
+```
+
+- [ ] **Step 6: Implement session and repository port**
+
+Create `VocabularySurveySessionRepository.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.util.Optional;
+
+public interface VocabularySurveySessionRepository {
+
+    VocabularySurveySession save(VocabularySurveySession session);
+    Optional<VocabularySurveySession> findById(String sessionId);
+    Optional<VocabularySurveySession> findActiveByStudentIdAndGrade(Integer studentId, int grade);
+}
+```
+
+Create `VocabularySurveySession.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.domain;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+public class VocabularySurveySession {
+
+    private final String sessionId;
+    private final Integer studentId;
+    private final int grade;
+    private final int gradeMaxFrequency;
+    private final int challengeMaxFrequency;
+    private int currentCenterFrequency;
+    private int currentWordWidth;
+    private VocabularySurveyStatus status;
+    private final Instant createdAt;
+    private final Instant expiresAt;
+    private Instant finishedAt;
+    private final List<VocabularySurveyLevel> levels = new ArrayList<>();
+    private VocabularySurveyReport report;
+
+    private VocabularySurveySession(
+            String sessionId,
+            Integer studentId,
+            int grade,
+            int gradeMaxFrequency,
+            int challengeMaxFrequency,
+            int currentCenterFrequency,
+            int currentWordWidth,
+            VocabularySurveyStatus status,
+            Instant createdAt,
+            Instant expiresAt) {
+        this.sessionId = sessionId;
+        this.studentId = studentId;
+        this.grade = grade;
+        this.gradeMaxFrequency = gradeMaxFrequency;
+        this.challengeMaxFrequency = challengeMaxFrequency;
+        this.currentCenterFrequency = currentCenterFrequency;
+        this.currentWordWidth = currentWordWidth;
+        this.status = status;
+        this.createdAt = createdAt;
+        this.expiresAt = expiresAt;
+    }
+
+    public static VocabularySurveySession start(
+            String sessionId,
+            Integer studentId,
+            int grade,
+            int gradeMaxFrequency,
+            int challengeMaxFrequency,
+            int currentCenterFrequency,
+            int currentWordWidth,
+            Instant createdAt,
+            Instant expiresAt) {
+        return new VocabularySurveySession(
+                sessionId,
+                studentId,
+                grade,
+                gradeMaxFrequency,
+                challengeMaxFrequency,
+                currentCenterFrequency,
+                currentWordWidth,
+                VocabularySurveyStatus.IN_PROGRESS,
+                createdAt,
+                expiresAt);
+    }
+
+    public String sessionId() { return sessionId; }
+    public Integer studentId() { return studentId; }
+    public int grade() { return grade; }
+    public int gradeMaxFrequency() { return gradeMaxFrequency; }
+    public int challengeMaxFrequency() { return challengeMaxFrequency; }
+    public int currentCenterFrequency() { return currentCenterFrequency; }
+    public int currentWordWidth() { return currentWordWidth; }
+    public VocabularySurveyStatus status() { return status; }
+    public Instant createdAt() { return createdAt; }
+    public Instant expiresAt() { return expiresAt; }
+    public Instant finishedAt() { return finishedAt; }
+    public List<VocabularySurveyLevel> levels() { return List.copyOf(levels); }
+    public VocabularySurveyReport report() { return report; }
+
+    public boolean isExpiredAt(Instant instant) {
+        return !expiresAt.isAfter(instant);
+    }
+
+    public void expire() {
+        status = VocabularySurveyStatus.EXPIRED;
+    }
+
+    public void cancel() {
+        status = VocabularySurveyStatus.CANCELLED;
+    }
+
+    public void addLevel(VocabularySurveyLevel level) {
+        levels.add(level);
+    }
+
+    public Optional<VocabularySurveyLevel> currentLevel() {
+        if (levels.isEmpty()) {
+            return Optional.empty();
+        }
+        return Optional.of(levels.get(levels.size() - 1));
+    }
+
+    public Set<Integer> testedWordIds() {
+        Set<Integer> wordIds = new HashSet<>();
+        for (VocabularySurveyLevel level : levels) {
+            for (VocabularySurveyWord word : level.words()) {
+                wordIds.add(word.wordId());
+            }
+        }
+        return wordIds;
+    }
+
+    public void replaceCurrentLevel(VocabularySurveyLevel submittedLevel) {
+        if (levels.isEmpty()) {
+            throw new IllegalStateException("session has no levels");
+        }
+        levels.set(levels.size() - 1, submittedLevel);
+    }
+
+    public void updateNextState(VocabularySurveyLevelPlanner.NextLevelState nextState) {
+        currentCenterFrequency = nextState.centerFrequency();
+        currentWordWidth = nextState.wordWidth();
+    }
+
+    public boolean shouldFinish() {
+        int completedLevelCount = (int) levels.stream().filter(VocabularySurveyLevel::submitted).count();
+        if (completedLevelCount >= 6) {
+            return true;
+        }
+        if (completedLevelCount >= 4 && lastTwoSubmittedLevelsNearBoundary()) {
+            return true;
+        }
+        return completedLevelCount >= 5;
+    }
+
+    private boolean lastTwoSubmittedLevelsNearBoundary() {
+        List<VocabularySurveyLevel> submittedLevels = levels.stream()
+                .filter(VocabularySurveyLevel::submitted)
+                .toList();
+        if (submittedLevels.size() < 2) {
+            return false;
+        }
+        VocabularySurveyLevel previous = submittedLevels.get(submittedLevels.size() - 2);
+        VocabularySurveyLevel current = submittedLevels.get(submittedLevels.size() - 1);
+        return previous.nearBoundary() && current.nearBoundary();
+    }
+
+    public void finish(VocabularySurveyReport report, Instant finishedAt) {
+        this.report = report;
+        this.finishedAt = finishedAt;
+        this.status = VocabularySurveyStatus.FINISHED;
+    }
+}
+```
+
+- [ ] **Step 7: Run domain tests**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain test
+```
+
+Expected: PASS.
+
+---
+
+## Task 5: Add in-memory repository and dictionary infrastructure
+
+**Files:**
+- Copy: `dict.data` to resources.
+- Create: infrastructure repository and dictionary classes.
+- Test: infrastructure repository and dictionary tests.
+
+- [ ] **Step 1: Copy the authorized dictionary file**
+
+Before copying, verify the parent destination exists or create it inside the workspace. Use PowerShell equivalent commands with quoted paths:
+
+```powershell
+Test-Path -LiteralPath "abilities\vocabulary-survey\infrastructure\src\main\resources"
+```
+
+If the path exists, create `data` under it and copy:
+
+```powershell
+New-Item -ItemType Directory -Force -Path "abilities\vocabulary-survey\infrastructure\src\main\resources\data"
+Copy-Item -LiteralPath "F:\Codes\Projects\qingti.teaching.api\Qingti.Teaching.Dictionary\Resources\dict.data" -Destination "abilities\vocabulary-survey\infrastructure\src\main\resources\data\dict.data"
+```
+
+Expected: `abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data` exists. Do not print file contents.
+
+- [ ] **Step 2: Write in-memory repository test**
+
+Create `InMemoryVocabularySurveySessionRepositoryTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.repository;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyGradePolicy;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyIds;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+import org.junit.jupiter.api.Test;
+
+import java.time.Instant;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class InMemoryVocabularySurveySessionRepositoryTest {
+
+    @Test
+    void saveAndFindByIdReturnsSession() {
+        InMemoryVocabularySurveySessionRepository repository = new InMemoryVocabularySurveySessionRepository();
+        VocabularySurveySession session = session(123, 7);
+
+        repository.save(session);
+
+        assertThat(repository.findById(session.sessionId())).containsSame(session);
+    }
+
+    @Test
+    void findActiveByStudentIdAndGradeSkipsCancelledSession() {
+        InMemoryVocabularySurveySessionRepository repository = new InMemoryVocabularySurveySessionRepository();
+        VocabularySurveySession session = session(123, 7);
+        session.cancel();
+        repository.save(session);
+
+        assertThat(repository.findActiveByStudentIdAndGrade(123, 7)).isEmpty();
+    }
+
+    private VocabularySurveySession session(Integer studentId, int grade) {
+        int gradeMax = VocabularySurveyGradePolicy.gradeMaxFrequency(grade);
+        return VocabularySurveySession.start(
+                VocabularySurveyIds.sessionId(),
+                studentId,
+                grade,
+                gradeMax,
+                VocabularySurveyGradePolicy.challengeMaxFrequency(grade, 5000),
+                (int) Math.round(0.35D * gradeMax),
+                gradeMax,
+                Instant.EPOCH,
+                Instant.EPOCH.plusSeconds(86400));
+    }
+}
+```
+
+- [ ] **Step 3: Implement in-memory repository**
+
+Create `InMemoryVocabularySurveySessionRepository.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.repository;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyStatus;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+@Repository
+public class InMemoryVocabularySurveySessionRepository implements VocabularySurveySessionRepository {
+
+    private final ConcurrentMap<String, VocabularySurveySession> storage = new ConcurrentHashMap<>();
+
+    @Override
+    public VocabularySurveySession save(VocabularySurveySession session) {
+        storage.put(session.sessionId(), session);
+        return session;
+    }
+
+    @Override
+    public Optional<VocabularySurveySession> findById(String sessionId) {
+        return Optional.ofNullable(storage.get(sessionId));
+    }
+
+    @Override
+    public Optional<VocabularySurveySession> findActiveByStudentIdAndGrade(Integer studentId, int grade) {
+        return storage.values().stream()
+                .filter(session -> studentId == null || studentId.equals(session.studentId()))
+                .filter(session -> session.grade() == grade)
+                .filter(session -> session.status() == VocabularySurveyStatus.IN_PROGRESS)
+                .findFirst();
+    }
+}
+```
+
+- [ ] **Step 4: Write dictionary provider smoke test**
+
+Create `MessagePackVocabularyWordProviderTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyProperties;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.DefaultResourceLoader;
+
+import java.util.Set;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class MessagePackVocabularyWordProviderTest {
+
+    @Test
+    void loadsDictionaryAndFindsWordsAroundFrequency() {
+        VocabularySurveyProperties properties = new VocabularySurveyProperties();
+        properties.getDictionary().setLocation("classpath:data/dict.data");
+        MessagePackVocabularyWordProvider provider = new MessagePackVocabularyWordProvider(
+                new MessagePackDictionaryLoader(new DefaultResourceLoader()),
+                properties);
+
+        assertThat(provider.maxWordFrequency()).isPositive();
+        assertThat(provider.findWordsAroundFrequency(500, 200, 12, Set.of()))
+                .hasSize(12)
+                .allSatisfy(word -> {
+                    assertThat(word.wordId()).isPositive();
+                    assertThat(word.meaningId()).isPositive();
+                    assertThat(word.spell()).isNotBlank();
+                    assertThat(word.wordFrequency()).isPositive();
+                });
+        VocabularyWord first = provider.findWordsAroundFrequency(500, 200, 1, Set.of()).get(0);
+        assertThat(provider.findByWordId(first.wordId())).contains(first);
+        assertThat(provider.findByMeaningId(first.meaningId())).contains(first);
+    }
+}
+```
+
+- [ ] **Step 5: Create dictionary data records**
+
+Create `DictionaryObject.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+import java.util.List;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record DictionaryObject(
+        List<DictionaryWord> words,
+        List<DictionaryExchange> exchanges,
+        List<DictionaryMeaning> meanings) {
+
+    public List<DictionaryWord> wordsOrEmpty() {
+        return words == null ? List.of() : words;
+    }
+}
+```
+
+Create `DictionaryWord.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record DictionaryWord(
+        @JsonAlias({"wordId", "WordId"}) Integer wordId,
+        @JsonAlias({"spell", "Spell", "wordSpell", "WordSpell"}) String spell,
+        @JsonAlias({"wordFrequency", "WordFrequency"}) Integer wordFrequency,
+        @JsonAlias({"meaningId", "MeaningId", "meanId", "MeanId"}) Integer meaningId) {
+}
+```
+
+Create `DictionaryMeaning.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import com.fasterxml.jackson.annotation.JsonAlias;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record DictionaryMeaning(
+        @JsonAlias({"meaningId", "MeaningId", "meanId", "MeanId"}) Integer meaningId,
+        @JsonAlias({"wordId", "WordId"}) Integer wordId) {
+}
+```
+
+Create `DictionaryExchange.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+
+@JsonIgnoreProperties(ignoreUnknown = true)
+public record DictionaryExchange() {
+}
+```
+
+- [ ] **Step 6: Implement MessagePack loader and provider**
+
+Create `MessagePackDictionaryLoader.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.msgpack.jackson.dataformat.MessagePackFactory;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.ResourceLoader;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.io.InputStream;
+
+@Component
+public class MessagePackDictionaryLoader {
+
+    private final ResourceLoader resourceLoader;
+    private final ObjectMapper objectMapper = new ObjectMapper(new MessagePackFactory());
+
+    public MessagePackDictionaryLoader(ResourceLoader resourceLoader) {
+        this.resourceLoader = resourceLoader;
+    }
+
+    public DictionaryObject load(String location) {
+        Resource resource = resourceLoader.getResource(location);
+        try (InputStream inputStream = resource.getInputStream()) {
+            DictionaryObject dictionaryObject = objectMapper.readValue(inputStream, DictionaryObject.class);
+            if (dictionaryObject == null || dictionaryObject.wordsOrEmpty().isEmpty()) {
+                throw new IllegalStateException("dictionary contains no words: " + location);
+            }
+            return dictionaryObject;
+        } catch (IOException exception) {
+            throw new IllegalStateException("failed to load vocabulary dictionary: " + location, exception);
+        }
+    }
+}
+```
+
+Create `MessagePackVocabularyWordProvider.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.infrastructure.dictionary;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyProperties;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
+import org.springframework.stereotype.Component;
+
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+@Component
+public class MessagePackVocabularyWordProvider implements VocabularyWordProvider {
+
+    private final List<VocabularyWord> sortedWords;
+    private final Map<Integer, VocabularyWord> wordsByWordId;
+    private final Map<Integer, VocabularyWord> wordsByMeaningId;
+    private final int maxWordFrequency;
+
+    public MessagePackVocabularyWordProvider(
+            MessagePackDictionaryLoader loader,
+            VocabularySurveyProperties properties) {
+        DictionaryObject dictionaryObject = loader.load(properties.getDictionary().getLocation());
+        this.sortedWords = dictionaryObject.wordsOrEmpty().stream()
+                .map(this::toVocabularyWord)
+                .flatMap(Optional::stream)
+                .sorted(Comparator.comparingInt(VocabularyWord::wordFrequency))
+                .toList();
+        this.wordsByWordId = new HashMap<>();
+        this.wordsByMeaningId = new HashMap<>();
+        for (VocabularyWord word : sortedWords) {
+            wordsByWordId.putIfAbsent(word.wordId(), word);
+            wordsByMeaningId.putIfAbsent(word.meaningId(), word);
+        }
+        this.maxWordFrequency = sortedWords.stream().mapToInt(VocabularyWord::wordFrequency).max().orElse(0);
+        if (sortedWords.isEmpty()) {
+            throw new IllegalStateException("dictionary contains no usable vocabulary words");
+        }
+    }
+
+    @Override
+    public List<VocabularyWord> findWordsAroundFrequency(
+            int centerFrequency,
+            int width,
+            int count,
+            Set<Integer> excludedWordIds) {
+        int halfWidth = Math.max(1, width / 2);
+        return sortedWords.stream()
+                .filter(word -> !excludedWordIds.contains(word.wordId()))
+                .sorted(Comparator
+                        .comparingInt((VocabularyWord word) -> Math.abs(word.wordFrequency() - centerFrequency))
+                        .thenComparingInt(VocabularyWord::wordFrequency)
+                        .thenComparingInt(VocabularyWord::wordId))
+                .filter(word -> Math.abs(word.wordFrequency() - centerFrequency) <= Math.max(halfWidth, 50)
+                        || sortedWords.size() <= count)
+                .limit(count)
+                .toList();
+    }
+
+    @Override
+    public Optional<VocabularyWord> findByWordId(int wordId) {
+        return Optional.ofNullable(wordsByWordId.get(wordId));
+    }
+
+    @Override
+    public Optional<VocabularyWord> findByMeaningId(int meaningId) {
+        return Optional.ofNullable(wordsByMeaningId.get(meaningId));
+    }
+
+    @Override
+    public int maxWordFrequency() {
+        return maxWordFrequency;
+    }
+
+    private Optional<VocabularyWord> toVocabularyWord(DictionaryWord word) {
+        if (word.wordId() == null || word.meaningId() == null || word.spell() == null || word.wordFrequency() == null) {
+            return Optional.empty();
+        }
+        if (word.wordId() <= 0 || word.meaningId() <= 0 || word.spell().isBlank() || word.wordFrequency() <= 0) {
+            return Optional.empty();
+        }
+        return Optional.of(new VocabularyWord(word.wordId(), word.meaningId(), word.spell(), word.wordFrequency()));
+    }
+}
+```
+
+If this test fails because the C# MessagePack file uses array keys instead of map keys, stop and ask for authorization to inspect the C# dictionary model source files. Do not guess field order from the binary file.
+
+- [ ] **Step 7: Run infrastructure tests**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/infrastructure test
+```
+
+Expected: PASS if MessagePack field names bind. If dictionary loading fails due MessagePack shape, report the exact exception type and request the C# model source mapping before editing parser code.
+
+---
+
+## Task 6: Add application service orchestration
+
+**Files:**
+- Create: application service, commands, mapper, properties.
+- Test: `DefaultVocabularySurveyApplicationServiceTest.java`.
+
+- [ ] **Step 1: Create application properties**
+
+Create `VocabularySurveyProperties.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import java.time.Duration;
+
+public class VocabularySurveyProperties {
+
+    private Duration sessionTtl = Duration.ofHours(24);
+    private final Dictionary dictionary = new Dictionary();
+
+    public Duration getSessionTtl() {
+        return sessionTtl;
+    }
+
+    public void setSessionTtl(Duration sessionTtl) {
+        this.sessionTtl = sessionTtl;
+    }
+
+    public Dictionary getDictionary() {
+        return dictionary;
+    }
+
+    public static class Dictionary {
+        private String location = "classpath:data/dict.data";
+
+        public String getLocation() {
+            return location;
+        }
+
+        public void setLocation(String location) {
+            this.location = location;
+        }
+    }
+}
+```
+
+- [ ] **Step 2: Create commands and service interface**
+
+Create `StartVocabularySurveyCommand.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+public record StartVocabularySurveyCommand(Integer studentId, int grade, boolean restart) {
+}
+```
+
+Create `SubmitVocabularySurveyLevelCommand.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import java.util.List;
+
+public record SubmitVocabularySurveyLevelCommand(
+        String sessionId,
+        String levelId,
+        List<String> unknownSurveyWordIds) {
+}
+```
+
+Create `VocabularySurveyApplicationService.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+
+public interface VocabularySurveyApplicationService {
+
+    VocabularySurveySessionResponse start(StartVocabularySurveyCommand command);
+    SubmitVocabularySurveyLevelResponse submitLevel(SubmitVocabularySurveyLevelCommand command);
+    VocabularySurveySessionResponse getSession(String sessionId);
+}
+```
+
+- [ ] **Step 3: Write application service tests**
+
+Create `DefaultVocabularySurveyApplicationServiceTest.java` with fake provider and repository helpers:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWord;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+class DefaultVocabularySurveyApplicationServiceTest {
+
+    private final Clock clock = Clock.fixed(Instant.parse("2026-05-24T00:00:00Z"), ZoneOffset.UTC);
+
+    @Test
+    void startCreatesFirstLevelWithTwelveWords() {
+        DefaultVocabularySurveyApplicationService service = service();
+
+        VocabularySurveySessionResponse response = service.start(new StartVocabularySurveyCommand(123, 7, false));
+
+        assertThat(response.sessionId()).startsWith("surv_");
+        assertThat(response.level().levelNo()).isEqualTo(1);
+        assertThat(response.level().words()).hasSize(12);
+    }
+
+    @Test
+    void startReturnsExistingActiveSessionUnlessRestartRequested() {
+        DefaultVocabularySurveyApplicationService service = service();
+        VocabularySurveySessionResponse first = service.start(new StartVocabularySurveyCommand(123, 7, false));
+
+        VocabularySurveySessionResponse second = service.start(new StartVocabularySurveyCommand(123, 7, false));
+
+        VocabularySurveySessionResponse restarted = service.start(new StartVocabularySurveyCommand(123, 7, true));
+
+        assertThat(second.sessionId()).isEqualTo(first.sessionId());
+        assertThat(restarted.sessionId()).isNotEqualTo(first.sessionId());
+    }
+
+    @Test
+    void submitUnknownWordsReturnsNextLevel() {
+        DefaultVocabularySurveyApplicationService service = service();
+        VocabularySurveySessionResponse started = service.start(new StartVocabularySurveyCommand(123, 7, false));
+
+        SubmitVocabularySurveyLevelResponse response = service.submitLevel(new SubmitVocabularySurveyLevelCommand(
+                started.sessionId(),
+                started.level().levelId(),
+                List.of(started.level().words().get(0).surveyWordId())));
+
+        assertThat(response.status().value()).isEqualTo("inProgress");
+        assertThat(response.submittedLevelNo()).isEqualTo(1);
+        assertThat(response.level().levelNo()).isEqualTo(2);
+    }
+
+    @Test
+    void submitRejectsSurveyWordFromOtherLevel() {
+        DefaultVocabularySurveyApplicationService service = service();
+        VocabularySurveySessionResponse started = service.start(new StartVocabularySurveyCommand(123, 7, false));
+
+        assertThatThrownBy(() -> service.submitLevel(new SubmitVocabularySurveyLevelCommand(
+                started.sessionId(),
+                started.level().levelId(),
+                List.of("sw_not_in_level"))))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("invalid survey word");
+    }
+
+    @Test
+    void repeatedSubmitIsIdempotent() {
+        DefaultVocabularySurveyApplicationService service = service();
+        VocabularySurveySessionResponse started = service.start(new StartVocabularySurveyCommand(123, 7, false));
+        SubmitVocabularySurveyLevelCommand command = new SubmitVocabularySurveyLevelCommand(
+                started.sessionId(),
+                started.level().levelId(),
+                List.of());
+
+        SubmitVocabularySurveyLevelResponse first = service.submitLevel(command);
+        SubmitVocabularySurveyLevelResponse second = service.submitLevel(command);
+
+        assertThat(second.level().levelId()).isEqualTo(first.level().levelId());
+    }
+
+    private DefaultVocabularySurveyApplicationService service() {
+        VocabularySurveyProperties properties = new VocabularySurveyProperties();
+        return new DefaultVocabularySurveyApplicationService(
+                new TestRepository(),
+                new FakeProvider(),
+                properties,
+                clock);
+    }
+
+    private static final class FakeProvider implements VocabularyWordProvider {
+        private final List<VocabularyWord> words = new ArrayList<>();
+
+        private FakeProvider() {
+            for (int index = 1; index <= 500; index++) {
+                words.add(new VocabularyWord(index, 1000 + index, "word" + index, index * 10));
+            }
+        }
+
+        @Override
+        public List<VocabularyWord> findWordsAroundFrequency(int centerFrequency, int width, int count, Set<Integer> excludedWordIds) {
+            return words.stream()
+                    .filter(word -> !excludedWordIds.contains(word.wordId()))
+                    .sorted((left, right) -> Integer.compare(
+                            Math.abs(left.wordFrequency() - centerFrequency),
+                            Math.abs(right.wordFrequency() - centerFrequency)))
+                    .limit(count)
+                    .toList();
+        }
+
+        @Override
+        public Optional<VocabularyWord> findByWordId(int wordId) {
+            return words.stream().filter(word -> word.wordId() == wordId).findFirst();
+        }
+
+        @Override
+        public Optional<VocabularyWord> findByMeaningId(int meaningId) {
+            return words.stream().filter(word -> word.meaningId() == meaningId).findFirst();
+        }
+
+        @Override
+        public int maxWordFrequency() {
+            return 5000;
+        }
+    }
+
+    private static final class TestRepository implements VocabularySurveySessionRepository {
+        private final ConcurrentMap<String, VocabularySurveySession> storage = new ConcurrentHashMap<>();
+
+        @Override
+        public VocabularySurveySession save(VocabularySurveySession session) {
+            storage.put(session.sessionId(), session);
+            return session;
+        }
+
+        @Override
+        public Optional<VocabularySurveySession> findById(String sessionId) {
+            return Optional.ofNullable(storage.get(sessionId));
+        }
+
+        @Override
+        public Optional<VocabularySurveySession> findActiveByStudentIdAndGrade(Integer studentId, int grade) {
+            return storage.values().stream()
+                    .filter(session -> studentId == null || studentId.equals(session.studentId()))
+                    .filter(session -> session.grade() == grade)
+                    .filter(session -> session.status() == cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyStatus.IN_PROGRESS)
+                    .findFirst();
+        }
+    }
+}
+```
+
+- [ ] **Step 4: Implement result mapper**
+
+Create `VocabularySurveyResultMapper.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyLearningRangeResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyReportResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyWordResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevel;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReport;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyWord;
+
+public final class VocabularySurveyResultMapper {
+
+    private VocabularySurveyResultMapper() {
+    }
+
+    public static VocabularySurveySessionResponse startResponse(VocabularySurveySession session) {
+        return VocabularySurveySessionResponse.started(
+                session.sessionId(),
+                session.grade(),
+                toLevelResponse(session.currentLevel().orElseThrow()));
+    }
+
+    public static VocabularySurveySessionResponse sessionResponse(VocabularySurveySession session) {
+        if (session.report() != null) {
+            return VocabularySurveySessionResponse.finished(session.sessionId(), session.grade(), toReportResponse(session.report()));
+        }
+        return VocabularySurveySessionResponse.current(
+                session.sessionId(),
+                session.grade(),
+                toLevelResponse(session.currentLevel().orElseThrow()));
+    }
+
+    public static VocabularySurveyLevelResponse toLevelResponse(VocabularySurveyLevel level) {
+        return new VocabularySurveyLevelResponse(
+                level.levelId(),
+                level.levelNo(),
+                level.wordCount(),
+                level.feedback(),
+                level.words().stream().map(VocabularySurveyResultMapper::toWordResponse).toList());
+    }
+
+    public static VocabularySurveyReportResponse toReportResponse(VocabularySurveyReport report) {
+        return new VocabularySurveyReportResponse(
+                report.vocabularySize(),
+                report.k(),
+                report.grade(),
+                report.gradeMaxFrequency(),
+                report.abilityLevel(),
+                new VocabularySurveyLearningRangeResponse(report.learningRange().min(), report.learningRange().max()),
+                report.testedWordCount(),
+                report.unknownWordCount(),
+                report.summary());
+    }
+
+    private static VocabularySurveyWordResponse toWordResponse(VocabularySurveyWord word) {
+        return new VocabularySurveyWordResponse(
+                word.surveyWordId(),
+                word.wordId(),
+                word.meaningId(),
+                word.spell(),
+                word.wordFrequency());
+    }
+}
+```
+
+- [ ] **Step 5: Implement application service**
+
+Create `DefaultVocabularySurveyApplicationService.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.application;
+
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyGradePolicy;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyIds;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyKEstimator;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevel;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyLevelPlanner;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReport;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyReportGenerator;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySession;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveySessionRepository;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularySurveyStatus;
+import cn.yunzhixue.ability.center.vocabularysurvey.domain.VocabularyWordProvider;
+import org.springframework.stereotype.Service;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+
+@Service
+public class DefaultVocabularySurveyApplicationService implements VocabularySurveyApplicationService {
+
+    private final VocabularySurveySessionRepository repository;
+    private final VocabularyWordProvider wordProvider;
+    private final VocabularySurveyProperties properties;
+    private final Clock clock;
+    private final VocabularySurveyLevelPlanner levelPlanner;
+    private final VocabularySurveyKEstimator kEstimator = new VocabularySurveyKEstimator();
+    private final VocabularySurveyReportGenerator reportGenerator = new VocabularySurveyReportGenerator();
+
+    public DefaultVocabularySurveyApplicationService(
+            VocabularySurveySessionRepository repository,
+            VocabularyWordProvider wordProvider,
+            VocabularySurveyProperties properties,
+            Clock clock) {
+        this.repository = repository;
+        this.wordProvider = wordProvider;
+        this.properties = properties;
+        this.clock = clock;
+        this.levelPlanner = new VocabularySurveyLevelPlanner(wordProvider);
+    }
+
+    @Override
+    public VocabularySurveySessionResponse start(StartVocabularySurveyCommand command) {
+        Instant now = clock.instant();
+        VocabularySurveyGradePolicy.gradeMaxFrequency(command.grade());
+        Optional<VocabularySurveySession> existing = repository
+                .findActiveByStudentIdAndGrade(command.studentId(), command.grade())
+                .filter(session -> !session.isExpiredAt(now));
+        if (existing.isPresent() && !command.restart()) {
+            return VocabularySurveyResultMapper.sessionResponse(existing.orElseThrow());
+        }
+        existing.ifPresent(session -> {
+            session.cancel();
+            repository.save(session);
+        });
+        return createNewSession(command, now);
+    }
+
+    private VocabularySurveySessionResponse createNewSession(StartVocabularySurveyCommand command, Instant now) {
+        int gradeMaxFrequency = VocabularySurveyGradePolicy.gradeMaxFrequency(command.grade());
+        int challengeMaxFrequency = VocabularySurveyGradePolicy.challengeMaxFrequency(command.grade(), wordProvider.maxWordFrequency());
+        int centerFrequency = (int) Math.round(0.35D * gradeMaxFrequency);
+        VocabularySurveySession session = VocabularySurveySession.start(
+                VocabularySurveyIds.sessionId(),
+                command.studentId(),
+                command.grade(),
+                gradeMaxFrequency,
+                challengeMaxFrequency,
+                centerFrequency,
+                gradeMaxFrequency,
+                now,
+                now.plus(properties.getSessionTtl()));
+        VocabularySurveyLevel level = levelPlanner.generateLevel(
+                session.sessionId(),
+                1,
+                session.currentCenterFrequency(),
+                session.currentWordWidth(),
+                Set.of(),
+                now);
+        session.addLevel(level);
+        repository.save(session);
+        return VocabularySurveyResultMapper.startResponse(session);
+    }
+
+    @Override
+    public SubmitVocabularySurveyLevelResponse submitLevel(SubmitVocabularySurveyLevelCommand command) {
+        Instant now = clock.instant();
+        VocabularySurveySession session = requireUsableSession(command.sessionId(), now);
+        VocabularySurveyLevel currentLevel = session.currentLevel()
+                .orElseThrow(() -> new BusinessException(ErrorCode.SURVEY_LEVEL_NOT_FOUND));
+        if (!currentLevel.levelId().equals(command.levelId())) {
+            throw new BusinessException(ErrorCode.SURVEY_LEVEL_NOT_CURRENT);
+        }
+        if (currentLevel.submitted()) {
+            return latestSubmitResponse(session, currentLevel.levelNo());
+        }
+        Set<String> levelWordIds = new HashSet<>();
+        currentLevel.words().forEach(word -> levelWordIds.add(word.surveyWordId()));
+        List<String> unknownIds = command.unknownSurveyWordIds() == null ? List.of() : command.unknownSurveyWordIds();
+        for (String unknownId : unknownIds) {
+            if (!levelWordIds.contains(unknownId)) {
+                throw new BusinessException(ErrorCode.INVALID_SURVEY_WORD, "invalid survey word: " + unknownId);
+            }
+        }
+        VocabularySurveyLevel submittedLevel = currentLevel.submitted(Set.copyOf(unknownIds), now);
+        session.replaceCurrentLevel(submittedLevel);
+        VocabularySurveyLevelPlanner.NextLevelState nextState = levelPlanner.nextState(
+                session.currentCenterFrequency(),
+                session.currentWordWidth(),
+                submittedLevel.unknownRate(),
+                session.challengeMaxFrequency());
+        session.updateNextState(nextState);
+        if (session.shouldFinish()) {
+            int preferred = preferredFrequency(session);
+            int k = kEstimator.estimateK(session.levels(), preferred);
+            VocabularySurveyReport report = reportGenerator.generate(
+                    session.grade(),
+                    session.gradeMaxFrequency(),
+                    session.challengeMaxFrequency(),
+                    k,
+                    session.levels());
+            session.finish(report, now);
+            repository.save(session);
+            return SubmitVocabularySurveyLevelResponse.finished(
+                    session.sessionId(),
+                    submittedLevel.levelNo(),
+                    VocabularySurveyResultMapper.toReportResponse(report));
+        }
+        VocabularySurveyLevel nextLevel = levelPlanner.generateLevel(
+                session.sessionId(),
+                submittedLevel.levelNo() + 1,
+                session.currentCenterFrequency(),
+                session.currentWordWidth(),
+                session.testedWordIds(),
+                now);
+        session.addLevel(nextLevel);
+        repository.save(session);
+        return SubmitVocabularySurveyLevelResponse.nextLevel(
+                session.sessionId(),
+                submittedLevel.levelNo(),
+                VocabularySurveyResultMapper.toLevelResponse(nextLevel));
+    }
+
+    @Override
+    public VocabularySurveySessionResponse getSession(String sessionId) {
+        VocabularySurveySession session = requireExistingSession(sessionId);
+        if (session.isExpiredAt(clock.instant()) && session.status() == VocabularySurveyStatus.IN_PROGRESS) {
+            session.expire();
+            repository.save(session);
+            throw new BusinessException(ErrorCode.SURVEY_SESSION_EXPIRED);
+        }
+        return VocabularySurveyResultMapper.sessionResponse(session);
+    }
+
+    private VocabularySurveySession requireUsableSession(String sessionId, Instant now) {
+        VocabularySurveySession session = requireExistingSession(sessionId);
+        if (session.isExpiredAt(now)) {
+            session.expire();
+            repository.save(session);
+            throw new BusinessException(ErrorCode.SURVEY_SESSION_EXPIRED);
+        }
+        if (session.status() == VocabularySurveyStatus.FINISHED) {
+            throw new BusinessException(ErrorCode.SURVEY_SESSION_FINISHED);
+        }
+        return session;
+    }
+
+    private VocabularySurveySession requireExistingSession(String sessionId) {
+        return repository.findById(sessionId)
+                .orElseThrow(() -> new BusinessException(ErrorCode.SURVEY_SESSION_NOT_FOUND));
+    }
+
+    private SubmitVocabularySurveyLevelResponse latestSubmitResponse(VocabularySurveySession session, int submittedLevelNo) {
+        if (session.report() != null) {
+            return SubmitVocabularySurveyLevelResponse.finished(
+                    session.sessionId(),
+                    submittedLevelNo,
+                    VocabularySurveyResultMapper.toReportResponse(session.report()));
+        }
+        return SubmitVocabularySurveyLevelResponse.nextLevel(
+                session.sessionId(),
+                submittedLevelNo,
+                VocabularySurveyResultMapper.toLevelResponse(session.currentLevel().orElseThrow()));
+    }
+
+    private int preferredFrequency(VocabularySurveySession session) {
+        return session.levels().stream()
+                .filter(VocabularySurveyLevel::nearBoundary)
+                .reduce((previous, current) -> current)
+                .map(VocabularySurveyLevel::centerFrequency)
+                .orElseGet(() -> session.currentLevel().map(VocabularySurveyLevel::centerFrequency).orElse(session.currentCenterFrequency()));
+    }
+
+}
+```
+
+- [ ] **Step 6: Run application tests**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/application test
+```
+
+Expected: PASS.
+
+---
+
+## Task 7: Add runtime config and controller
+
+**Files:**
+- Create: runtime configuration and controller.
+- Test: WebMvc controller test.
+
+- [ ] **Step 1: Create runtime configuration**
+
+Create `VocabularySurveyRuntimeConfiguration.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.configuration;
+
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyProperties;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EnableConfigurationProperties(VocabularySurveyRuntimeConfiguration.BoundVocabularySurveyProperties.class)
+public class VocabularySurveyRuntimeConfiguration {
+
+    @Bean
+    public VocabularySurveyProperties vocabularySurveyProperties(BoundVocabularySurveyProperties bound) {
+        VocabularySurveyProperties properties = new VocabularySurveyProperties();
+        properties.setSessionTtl(bound.getSessionTtl());
+        properties.getDictionary().setLocation(bound.getDictionary().getLocation());
+        return properties;
+    }
+
+    @ConfigurationProperties(prefix = "ability.vocabulary-survey")
+    public static class BoundVocabularySurveyProperties extends VocabularySurveyProperties {
+    }
+}
+```
+
+Do not define a second `Clock` bean because `ExamSprintReportRuntimeConfiguration` already exposes `systemClock()` in runtime.
+
+- [ ] **Step 2: Create controller**
+
+Create `VocabularySurveyController.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.adapter.http;
+
+import cn.yunzhixue.ability.center.kernel.BaseResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.StartVocabularySurveyCommand;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.SubmitVocabularySurveyLevelCommand;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyApplicationService;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.StartVocabularySurveyRequest;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelRequest;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import jakarta.validation.Valid;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/mini-program/vocabulary-survey")
+public class VocabularySurveyController {
+
+    private final VocabularySurveyApplicationService applicationService;
+
+    public VocabularySurveyController(VocabularySurveyApplicationService applicationService) {
+        this.applicationService = applicationService;
+    }
+
+    @PostMapping("/start")
+    public BaseResponse<VocabularySurveySessionResponse> start(@Valid @RequestBody StartVocabularySurveyRequest request) {
+        return BaseResponse.success(applicationService.start(new StartVocabularySurveyCommand(
+                request.studentId(),
+                request.grade(),
+                request.restartRequested())));
+    }
+
+    @PostMapping("/levels/{levelId}/submit")
+    public BaseResponse<SubmitVocabularySurveyLevelResponse> submitLevel(
+            @PathVariable String levelId,
+            @Valid @RequestBody SubmitVocabularySurveyLevelRequest request) {
+        return BaseResponse.success(applicationService.submitLevel(new SubmitVocabularySurveyLevelCommand(
+                request.sessionId(),
+                levelId,
+                request.unknownSurveyWordIdsOrEmpty())));
+    }
+
+    @GetMapping("/sessions/{sessionId}")
+    public BaseResponse<VocabularySurveySessionResponse> getSession(@PathVariable String sessionId) {
+        return BaseResponse.success(applicationService.getSession(sessionId));
+    }
+}
+```
+
+- [ ] **Step 3: Write WebMvc tests**
+
+Create `VocabularySurveyControllerWebMvcTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.vocabularysurvey.adapter.http;
+
+import cn.yunzhixue.ability.center.GlobalExceptionHandler;
+import cn.yunzhixue.ability.center.kernel.BusinessException;
+import cn.yunzhixue.ability.center.kernel.ErrorCode;
+import cn.yunzhixue.ability.center.vocabularysurvey.application.VocabularySurveyApplicationService;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.SubmitVocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyLevelResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveySessionResponse;
+import cn.yunzhixue.ability.center.vocabularysurvey.contracts.VocabularySurveyWordResponse;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+
+import java.util.List;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@WebMvcTest(VocabularySurveyController.class)
+@Import({VocabularySurveyController.class, GlobalExceptionHandler.class})
+class VocabularySurveyControllerWebMvcTest {
+
+    @Autowired
+    private MockMvc mockMvc;
+
+    @MockBean
+    private VocabularySurveyApplicationService applicationService;
+
+    @Test
+    void startReturnsFirstLevel() throws Exception {
+        given(applicationService.start(any())).willReturn(VocabularySurveySessionResponse.started(
+                "surv_001",
+                7,
+                level(1)));
+
+        mockMvc.perform(post("/mini-program/vocabulary-survey/start")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"studentId\":123,\"grade\":7}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.sessionId").value("surv_001"))
+                .andExpect(jsonPath("$.data.status").value("inProgress"))
+                .andExpect(jsonPath("$.data.level.words[0].spell").value("happy"));
+    }
+
+    @Test
+    void submitReturnsNextLevel() throws Exception {
+        given(applicationService.submitLevel(any())).willReturn(SubmitVocabularySurveyLevelResponse.nextLevel(
+                "surv_001",
+                1,
+                level(2)));
+
+        mockMvc.perform(post("/mini-program/vocabulary-survey/levels/{levelId}/submit", "lvl_001")
+                        .contentType(MediaType.APPLICATION_JSON)
+                        .content("{\"sessionId\":\"surv_001\",\"unknownSurveyWordIds\":[\"sw_001\"]}"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.data.submittedLevelNo").value(1))
+                .andExpect(jsonPath("$.data.level.levelNo").value(2));
+    }
+
+    @Test
+    void getSessionMapsBusinessError() throws Exception {
+        given(applicationService.getSession(eq("missing")))
+                .willThrow(new BusinessException(ErrorCode.SURVEY_SESSION_NOT_FOUND));
+
+        mockMvc.perform(get("/mini-program/vocabulary-survey/sessions/{sessionId}", "missing"))
+                .andExpect(status().isNotFound())
+                .andExpect(jsonPath("$.code").value("SURVEY_SESSION_NOT_FOUND"));
+    }
+
+    private VocabularySurveyLevelResponse level(int levelNo) {
+        return new VocabularySurveyLevelResponse(
+                "lvl_00" + levelNo,
+                levelNo,
+                12,
+                "feedback",
+                List.of(new VocabularySurveyWordResponse("sw_001", 101, 2001, "happy", 403)));
+    }
+
+    @SpringBootConfiguration
+    @EnableAutoConfiguration
+    static class TestApplication {
+    }
+}
+```
+
+- [ ] **Step 4: Run runtime WebMvc tests**
+
+Run:
+
+```powershell
+mvn -pl ability-center-runtime -Dtest=VocabularySurveyControllerWebMvcTest test
+```
+
+Expected: PASS.
+
+---
+
+## Task 8: Add architecture tests
+
+**Files:**
+- Create: `ability-center-runtime/src/test/java/cn/yunzhixue/ability/center/architecture/VocabularySurveyArchitectureTest.java`
+
+- [ ] **Step 1: Write architecture rules**
+
+Create `VocabularySurveyArchitectureTest.java`:
+
+```java
+package cn.yunzhixue.ability.center.architecture;
+
+import com.tngtech.archunit.core.importer.ImportOption;
+import com.tngtech.archunit.junit.AnalyzeClasses;
+import com.tngtech.archunit.junit.ArchTest;
+import com.tngtech.archunit.lang.ArchRule;
+
+import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses;
+
+@AnalyzeClasses(
+        packages = "cn.yunzhixue.ability.center.vocabularysurvey",
+        importOptions = ImportOption.DoNotIncludeTests.class)
+class VocabularySurveyArchitectureTest {
+
+    @ArchTest
+    static final ArchRule contracts_should_not_depend_on_inner_layers = noClasses()
+            .that().resideInAPackage("..contracts..")
+            .should().dependOnClassesThat().resideInAnyPackage(
+                    "..domain..",
+                    "..application..",
+                    "..infrastructure..",
+                    "..adapter.."
+            );
+
+    @ArchTest
+    static final ArchRule domain_should_not_depend_on_contracts = noClasses()
+            .that().resideInAPackage("..domain..")
+            .should().dependOnClassesThat().resideInAPackage("..contracts..");
+
+    @ArchTest
+    static final ArchRule domain_should_not_depend_on_jackson_or_msgpack = noClasses()
+            .that().resideInAPackage("..domain..")
+            .should().dependOnClassesThat().resideInAnyPackage(
+                    "com.fasterxml.jackson..",
+                    "org.msgpack.."
+            );
+
+    @ArchTest
+    static final ArchRule infrastructure_should_not_depend_on_runtime_adapters = noClasses()
+            .that().resideInAPackage("..infrastructure..")
+            .should().dependOnClassesThat().resideInAnyPackage(
+                    "..adapter..",
+                    "..configuration.."
+            );
+}
+```
+
+- [ ] **Step 2: Run architecture test**
+
+Run:
+
+```powershell
+mvn -pl ability-center-runtime -Dtest=VocabularySurveyArchitectureTest test
+```
+
+Expected: PASS.
+
+---
+
+## Task 9: Run targeted and full verification
+
+**Files:**
+- No new files.
+- Verify changed modules.
+
+- [ ] **Step 1: Run contracts tests**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/contracts test
+```
+
+Expected: PASS.
+
+- [ ] **Step 2: Run domain tests**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain test
+```
+
+Expected: PASS.
+
+- [ ] **Step 3: Run application tests**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/application test
+```
+
+Expected: PASS.
+
+- [ ] **Step 4: Run infrastructure tests**
+
+Run:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/infrastructure test
+```
+
+Expected: PASS. If MessagePack binding fails, report the exact exception and do not claim infrastructure completion.
+
+- [ ] **Step 5: Run runtime targeted tests**
+
+Run:
+
+```powershell
+mvn -pl ability-center-runtime -Dtest=VocabularySurveyControllerWebMvcTest,VocabularySurveyArchitectureTest test
+```
+
+Expected: PASS.
+
+- [ ] **Step 6: Run full test suite when targeted tests pass**
+
+Run:
+
+```powershell
+mvn test
+```
+
+Expected: PASS. If existing Playwright/PDF tests fail for unrelated environment reasons, capture the failing command, failing test names, and first relevant error message.
+
+- [ ] **Step 7: Review diff and generated binary file**
+
+Run:
+
+```powershell
+git status --short
+git diff --stat
+git diff --check
+```
+
+Expected:
+
+- New vocabulary survey modules are listed.
+- `dict.data` is present under `abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data`.
+- No whitespace errors from `git diff --check`.
+- No real database password or token is newly added by this implementation.
+
+---
+
+## Self-review checklist for the implementer
+
+- [ ] Spec coverage: start, submit, get session, grade mapping, adaptive levels, K estimation, report fields, in-memory idempotency, MessagePack dictionary, and tests all have implementation tasks above.
+- [ ] Boundary coverage: domain does not depend on contracts, Jackson, MessagePack, Spring, or runtime adapters.
+- [ ] Secret handling: existing secret-bearing configuration is preserved but not printed or duplicated; new datasource credentials remain blank.
+- [ ] Runtime behavior: start returns an existing active session unless `restart=true`; submit is idempotent for already submitted current levels.
+- [ ] Single-node limitation: session persistence remains in memory and is documented in design and final report.
+- [ ] Verification evidence: final response reports exact commands run and their outcomes.
+
+---
+
+## Handoff note
+
+This plan intentionally omits commit commands because the current session has no explicit user authorization to commit. If the user later authorizes commits, commit after coherent checkpoints with messages matching repository style, for example `feat(词汇摸底): 新增领域模型和算法测试`.

+ 749 - 0
docs/superpowers/specs/2026-05-24-vocabulary-survey-design.md

@@ -0,0 +1,749 @@
+# 小程序词汇摸底后端能力设计
+
+> Status: 用户已确认设计方向,设计文档已写入,待用户审阅后进入实施计划。
+
+## 目标
+
+为当前项目新增“小程序词汇摸底”后端能力。初版只实现后端接口、服务、算法、词库加载、内存 session 和测试,不实现小程序 UI。
+
+核心业务流程:
+
+1. 小程序传入年级开始一次摸底 session。
+2. 后端返回第 1 关词列表。
+3. 每关展示 12 个英文单词,学生只勾选“不认识”的词。
+4. 小程序提交本关不认识词。
+5. 后端根据本关 unknownRate 调整下一关中心词频和取词宽度。
+6. 达到停止条件后生成报告,返回词汇量、K 值、能力等级和学习区间。
+
+初版运行环境假设:单机部署。session、level、word 明细使用内存保存;同一进程内支持断点恢复和重复提交幂等,服务重启后 session 丢失可接受。
+
+## 当前项目上下文
+
+只读调研结论:
+
+- 技术栈:Java 17、Spring Boot 3.3.5、Maven multi-module。
+- 当前模块:
+  - `ability-center-runtime`
+  - `ability-center-kernel`
+  - `abilities/exam-sprint/contracts`
+  - `abilities/exam-sprint/application`
+  - `abilities/exam-sprint/domain`
+  - `abilities/exam-sprint/infrastructure`
+- Controller 风格:`@RestController` + `BaseResponse<T>`,业务异常通过 `BusinessException + ErrorCode + GlobalExceptionHandler` 映射。
+- 分层风格:`contracts` 放外部 DTO,`application` 编排 use case,`domain` 放业务模型和 port,`infrastructure` 放实现。
+- 数据访问:当前未使用 JPA/MyBatis/JDBC;现有 report repository 是内存实现。
+- 认证方式:当前未看到登录态/鉴权接入。
+- 测试框架:JUnit 5、AssertJ、Spring Boot Test、MockMvc、WebMvcTest、ArchUnit。
+- 词库来源:当前仓库没有直接词库表或词库资源。
+
+注意:调研中发现 `ability-center-runtime/src/main/resources/application-test.yml` 存在敏感配置键。后续不输出其中 secret 值;实现时避免新增真实用户名、密码、token。
+
+## 词库来源
+
+词库不通过 RDS 表查询。
+
+真实词库来自 `Qingti.Teaching.Content.Dictionary.EnglishDictionary` 加载的 MessagePack 二进制文件,现有 .NET 业务通过 `IEnglishDictionary` 查询:
+
+- `GetWordItemByWordFrequency`
+- `GetWordItemByMeanId`
+- `this[int wordId]`
+
+本次 Java 微服务初版直接加载 MessagePack `dict.data`。用户授权从本机路径复制文件到当前项目:
+
+```text
+源文件:F:\Codes\Projects\qingti.teaching.api\Qingti.Teaching.Dictionary\Resources\dict.data
+目标文件:abilities/vocabulary-survey/infrastructure/src/main/resources/data/dict.data
+```
+
+顶层 MessagePack 对象:
+
+```text
+DictionaryObject
+├── words: DictionaryWord[]
+├── exchanges: DictionaryExchange[]
+└── meanings: DictionaryMeaning[]
+```
+
+小程序摸底选词优先基于 `wordFrequency`,核心字段为:
+
+- `wordId`
+- `spell`
+- `wordFrequency`
+- `meaningId`
+
+实现时允许新增 MessagePack 解析依赖,优先尝试:
+
+```xml
+<dependency>
+  <groupId>org.msgpack</groupId>
+  <artifactId>jackson-dataformat-msgpack</artifactId>
+</dependency>
+```
+
+如果真实文件结构不适合 Jackson 绑定,再改用 `org.msgpack:msgpack-core` 做低层解析。
+
+## 方案选择
+
+已比较 3 种方案:
+
+### 方案 A:独立能力模块 + 复制并加载 `dict.data` + 内存 session(选定)
+
+- 新增独立 `vocabulary-survey` ability。
+- 复制 `dict.data` 到本项目资源目录,服务随包加载。
+- `infrastructure` 启动加载 MessagePack 并建立索引。
+- `application/domain` 只依赖词库 port,不直接依赖 MessagePack。
+- session 明细用内存 repository。
+- 测试用 fake dictionary,真实文件加载作为独立基础设施测试。
+
+优点:符合真实词库机制,符合当前 DDD 分层,接口和算法可测试,部署时不依赖跨仓库文件路径。
+
+缺点:会把二进制词库文件纳入本项目;词库更新需要替换资源并重新发布。
+
+### 方案 B:先 fake dictionary,真实 MessagePack adapter 后补
+
+优点:实现风险最低。缺点:不能用真实词库验收选词效果,不符合本次接入方向。
+
+### 方案 C:外置路径加载 `dict.data`
+
+优点:词库不进入仓库。缺点:部署依赖额外文件路径配置,不如本次单机随包部署简单。
+
+## 模块结构
+
+新增独立能力模块:
+
+```text
+abilities/vocabulary-survey
+├── contracts
+│   └── src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/contracts
+├── domain
+│   └── src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/domain
+├── application
+│   └── src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/application
+└── infrastructure
+    ├── src/main/java/cn/yunzhixue/ability/center/vocabularysurvey/infrastructure
+    └── src/main/resources/data/dict.data
+```
+
+根 `pom.xml` 增加模块:
+
+```xml
+<module>abilities/vocabulary-survey/contracts</module>
+<module>abilities/vocabulary-survey/domain</module>
+<module>abilities/vocabulary-survey/application</module>
+<module>abilities/vocabulary-survey/infrastructure</module>
+```
+
+`ability-center-runtime` 增加依赖:
+
+- `vocabulary-survey-contracts`
+- `vocabulary-survey-application`
+- `vocabulary-survey-domain`
+- `vocabulary-survey-infrastructure`
+
+runtime 只放:
+
+- HTTP controller
+- runtime 配置绑定
+- 必要 Spring bean 装配
+
+业务规则、算法和报告生成不放 runtime。
+
+## 分层职责
+
+### contracts
+
+外部接口 DTO 和 API-facing enum:
+
+- `StartVocabularySurveyRequest`
+- `SubmitVocabularySurveyLevelRequest`
+- `VocabularySurveySessionResponse`
+- `VocabularySurveyLevelResponse`
+- `VocabularySurveyWordResponse`
+- `VocabularySurveyReportResponse`
+- `VocabularySurveyStatus`
+
+DTO 使用小程序期望的 JSON 字段:
+
+- `sessionId`
+- `status`
+- `grade`
+- `level`
+- `currentLevel`
+- `report`
+- `surveyWordId`
+- `wordId`
+- `meaningId`
+- `spell`
+- `wordFrequency`
+
+### domain
+
+业务模型和算法:
+
+- `VocabularySurveySession`
+- `VocabularySurveyLevel`
+- `VocabularySurveyWord`
+- `VocabularySurveyReport`
+- `VocabularySurveyStatus`
+- `VocabularySurveyGradePolicy`
+- `VocabularySurveyLevelPlanner`
+- `VocabularySurveyKEstimator`
+- `VocabularySurveyReportGenerator`
+- `VocabularyWordProvider` port
+- `VocabularySurveySessionRepository` port
+
+domain 不依赖 contracts、Spring、Jackson、MessagePack。
+
+### application
+
+用例编排:
+
+- `VocabularySurveyApplicationService`
+- `DefaultVocabularySurveyApplicationService`
+- `StartVocabularySurveyCommand`
+- `SubmitVocabularySurveyLevelCommand`
+- `VocabularySurveyResultMapper`
+- session 过期和 restart 编排
+
+application 依赖 domain,返回 contracts DTO 或 application result。为了贴合现有项目风格,初版可由 application service 直接返回 contracts response,但 domain 仍不依赖 contracts。
+
+### infrastructure
+
+技术实现:
+
+- `MessagePackVocabularyWordProvider`
+- `MessagePackDictionaryLoader`
+- `DictionaryObject`
+- `DictionaryWord`
+- `DictionaryMeaning`
+- `DictionaryExchange`
+- `InMemoryVocabularySurveySessionRepository`
+
+`MessagePackVocabularyWordProvider` 启动时加载 `classpath:data/dict.data`,建立索引:
+
+- `wordId -> VocabularyWord`
+- `meaningId -> VocabularyWord` 或 `meaningId -> DictionaryMeaning + word`
+- `wordFrequency -> List<VocabularyWord>`
+- `sortedByFrequency`
+
+## 词库 port 设计
+
+业务算法不直接读取 `dict.data`,也不关心 MessagePack 结构。domain 定义一个词库查询接口,例如:
+
+```java
+public interface VocabularyWordProvider {
+
+    List<VocabularyWord> findWordsAroundFrequency(
+            int centerFrequency,
+            int width,
+            int count,
+            Set<Integer> excludedWordIds);
+
+    Optional<VocabularyWord> findByWordId(int wordId);
+
+    Optional<VocabularyWord> findByMeaningId(int meaningId);
+
+    int maxWordFrequency();
+}
+```
+
+domain 使用的单词模型:
+
+```java
+public record VocabularyWord(
+        int wordId,
+        int meaningId,
+        String spell,
+        int wordFrequency) {
+}
+```
+
+这样可以保证:
+
+- 摸底算法只表达“我要某个词频附近的词”。
+- MessagePack、文件路径、索引结构只存在于 infrastructure。
+- 单元测试可以使用 fake provider。
+- 将来改成 OSS、HTTP 或数据库词库时,只替换 provider 实现。
+
+## HTTP 接口
+
+接口路径按需求实现,不额外加 `/api` 前缀:
+
+### 开始摸底
+
+```text
+POST /mini-program/vocabulary-survey/start
+```
+
+请求:
+
+```json
+{
+  "studentId": 123,
+  "grade": 7,
+  "restart": false
+}
+```
+
+`studentId` 在当前无登录态时用于幂等恢复;如果缺失,可用匿名 session 策略,但推荐小程序传入。
+
+响应:
+
+以下响应示例中的 `surv_xxx`、`lvl_xxx`、`sw_xxx` 是示例 ID,不是未决内容;实现时使用对应前缀生成真实 ID。
+
+```json
+{
+  "sessionId": "surv_xxx",
+  "status": "inProgress",
+  "grade": 7,
+  "level": {
+    "levelId": "lvl_xxx",
+    "levelNo": 1,
+    "wordCount": 12,
+    "feedback": "先来热个身,选出你不认识的单词吧",
+    "words": [
+      {
+        "surveyWordId": "sw_xxx",
+        "wordId": 101,
+        "meaningId": 2001,
+        "spell": "happy",
+        "wordFrequency": 403
+      }
+    ]
+  }
+}
+```
+
+### 提交某一关
+
+```text
+POST /mini-program/vocabulary-survey/levels/{levelId}/submit
+```
+
+请求:
+
+```json
+{
+  "sessionId": "surv_xxx",
+  "unknownSurveyWordIds": ["sw_001", "sw_002"]
+}
+```
+
+继续下一关响应:
+
+```json
+{
+  "sessionId": "surv_xxx",
+  "status": "inProgress",
+  "submittedLevelNo": 1,
+  "level": {
+    "levelId": "lvl_xxx",
+    "levelNo": 2,
+    "wordCount": 12,
+    "feedback": "本关完成,下一关挑战稍高一点的词!",
+    "words": []
+  }
+}
+```
+
+完成响应:
+
+```json
+{
+  "sessionId": "surv_xxx",
+  "status": "finished",
+  "submittedLevelNo": 5,
+  "report": {
+    "vocabularySize": 1380,
+    "k": 1450,
+    "grade": 7,
+    "gradeMaxFrequency": 1600,
+    "abilityLevel": "年级稳定",
+    "learningRange": {
+      "min": 1210,
+      "max": 1690
+    },
+    "testedWordCount": 60,
+    "unknownWordCount": 18,
+    "summary": "你的词汇基础比较稳定,适合继续挑战七年级中高阶词汇。"
+  }
+}
+```
+
+### 查询 session 状态
+
+```text
+GET /mini-program/vocabulary-survey/sessions/{sessionId}
+```
+
+进行中返回当前关,已完成返回报告。过期 session 初版返回业务错误码 `SURVEY_SESSION_EXPIRED`,避免前端继续提交旧 session。
+
+## 年级策略
+
+年级词频上限:
+
+| 年级 | 默认词频上限 |
+|---|---:|
+| 3 | 400 |
+| 4 | 700 |
+| 5 | 1000 |
+| 6 | 1200 |
+| 7 | 1600 |
+| 8 | 2000 |
+| 9 | 2400 |
+| 10 | 3200 |
+| 11 | 4000 |
+| 12 | 4800 |
+
+非法年级返回 `INVALID_GRADE`。
+
+初始化:
+
+```text
+gradeMaxFrequency = 根据年级映射得到
+challengeMaxFrequency = 下一年级上限;最高不超过词库最大 wordFrequency
+currentCenterFrequency = 0.35 * gradeMaxFrequency
+currentWordWidth = gradeMaxFrequency
+minWordWidth = 100
+```
+
+## session 流程
+
+### start
+
+1. 校验年级。
+2. 如果同一 `studentId + grade` 已有未完成且未过期 session:
+   - `restart != true`:返回已有 session 当前关。
+   - `restart == true`:旧 session 标记 `cancelled`,创建新 session。
+3. 初始化算法状态。
+4. 生成第 1 关 12 个词。
+5. 保存到内存 repository。
+
+### submit
+
+1. 校验 session 存在、未过期、未 finished。
+2. 校验 level 存在且是当前关。
+3. 校验所有 `unknownSurveyWordIds` 属于当前 session 和当前 level。
+4. 如果本关已经提交过,直接返回 session 最新状态,不重复计算。
+5. 未提交的本关词默认认识。
+6. 计算 unknownRate 并更新 level。
+7. 判断是否结束;未结束则生成下一关。
+8. 保存并返回最新状态。
+
+### get session
+
+1. session 不存在返回 `SURVEY_SESSION_NOT_FOUND`。
+2. session 过期返回 `SURVEY_SESSION_EXPIRED`,并可在内存中标记 `expired`。
+3. `inProgress` 返回当前关。
+4. `finished` 返回报告。
+
+## 选词算法
+
+每关固定 `wordCount = 12`。
+
+1. 以 `currentCenterFrequency` 为中心,计算窗口:
+
+```text
+left = center - width / 2
+right = center + width / 2
+```
+
+2. 在 `[left, right]` 内生成 12 个分散采样点。
+3. 对每个采样点,在词库索引中查找附近词。
+4. 排除当前 session 已出过的 `wordId`,并避免同一关重复。
+5. 局部找不到时扩大查找半径。
+6. 仍不足 12 个则抛 `SURVEY_WORD_GENERATION_FAILED`。
+
+`surveyWordId` 使用 `sw_` 前缀加随机或序列化 ID;`levelId` 使用 `lvl_`;`sessionId` 使用 `surv_`。
+
+## 收敛算法
+
+提交后:
+
+```text
+unknownRate = unknownCount / wordCount
+nextWordWidth = max(currentWordWidth * 0.75, 100)
+offset = nextWordWidth / 6
+```
+
+规则:
+
+```text
+unknownRate <= 0.4:
+  nextCenter = currentCenterFrequency + offset
+
+unknownRate >= 0.6:
+  nextCenter = currentCenterFrequency - offset
+
+0.4 < unknownRate < 0.6:
+  nextCenter = currentCenterFrequency
+```
+
+边界:
+
+```text
+100 <= nextCenter <= challengeMaxFrequency
+```
+
+## 停止条件
+
+初版采用确定顺序,避免默认 5 关与最多 6 关产生歧义:
+
+```text
+if completedLevelCount >= 6: end
+else if completedLevelCount >= 4 && recent two levels boundary: end
+else if completedLevelCount >= 5: end
+else continue
+```
+
+“接近边界”定义为 `0.4 < unknownRate < 0.6`。
+
+该策略满足:
+
+- 最少 4 关。
+- 默认 5 关。
+- 最多 6 关。
+- 最近两关均接近边界可提前结束。
+
+## K 值估算
+
+1. 收集所有已测词。
+2. 按 `wordFrequency` 排序。
+3. 遍历候选 K,计算:
+
+```text
+error(K) = K 左侧不认识词数量 + K 右侧认识词数量
+```
+
+约定:
+
+- `wordFrequency <= K` 属于 K 左侧。
+- `wordFrequency > K` 属于 K 右侧。
+
+4. 选择 error 最小的 K。
+5. 如果多个 K 相同,优先选择最接近最近边界关卡中心频率的 K;如果没有边界关,则选择最接近最后一关 center 的 K。
+
+## 词汇量估算
+
+初版没有现成词汇量估算模型可复用,因此采用:
+
+```text
+vocabularySize = K
+```
+
+原因:`wordFrequency` 在当前词库中接近 rank/频段位置,用 K 作为初版词汇量估算最稳定、最可解释。后续如引入完整掌握度模型,可升级为基于实测词掌握度和未实测词估计掌握度的聚合模型。
+
+## 能力等级
+
+根据:
+
+```text
+ratio = K / gradeMaxFrequency
+```
+
+映射:
+
+| ratio | abilityLevel |
+|---:|---|
+| < 0.45 | 基础待加强 |
+| 0.45 - 0.75 | 年级基础 |
+| 0.75 - 1.05 | 年级稳定 |
+| 1.05 - 1.25 | 超前挑战 |
+| > 1.25 | 明显超前 |
+
+边界采用左闭右开以避免重叠:
+
+- `< 0.45`
+- `>= 0.45 && < 0.75`
+- `>= 0.75 && < 1.05`
+- `>= 1.05 && <= 1.25`
+- `> 1.25`
+
+## 学习区间
+
+根据 K 返回:
+
+```text
+min = K - 0.15 * gradeMaxFrequency
+max = K + 0.15 * gradeMaxFrequency
+```
+
+边界限制:
+
+```text
+min >= 1
+max <= challengeMaxFrequency
+```
+
+## 报告文案
+
+根据能力等级生成 summary:
+
+- 基础待加强:`建议先巩固本年级核心高频词,再逐步挑战更高频段。`
+- 年级基础:`你的词汇基础已具备雏形,建议继续巩固本年级常见词。`
+- 年级稳定:`你的词汇基础比较稳定,适合继续挑战本年级中高阶词汇。`
+- 超前挑战:`你的词汇能力已经超过当前年级基础要求,可以适当挑战更高频段词汇。`
+- 明显超前:`你的词汇掌握明显超前,可以尝试挑战更高年级词汇。`
+
+## 配置设计
+
+### 词库配置
+
+默认使用 classpath 资源:
+
+```yaml
+ability:
+  vocabulary-survey:
+    dictionary:
+      location: classpath:data/dict.data
+```
+
+### 数据库配置
+
+按用户要求直接创建实际配置文本,用户名和密码留空,由用户本地填写。不使用 `.example` 模板。
+
+实现时需要合并到现有配置文件,不能覆盖 `ability.exam-sprint.report` 等已有配置;尤其是 `application-test.yml` 中已有的 report 测试配置必须保留,只新增或更新 `spring.datasource` 和 `ability.vocabulary-survey` 相关配置。
+
+`ability-center-runtime/src/main/resources/application-dev.yml` 指向 `qingti_db_dev`:
+
+```yaml
+spring:
+  datasource:
+    url: jdbc:mysql://rm-uf6881jgyy065rdxdoo.rwlb.rds.aliyuncs.com:3306/qingti_db_dev?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
+    username:
+    password:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+```
+
+`ability-center-runtime/src/main/resources/application-test.yml` 指向 `qingti_test`:
+
+```yaml
+spring:
+  datasource:
+    url: jdbc:mysql://rm-uf6881jgyy065rdxdoo.rwlb.rds.aliyuncs.com:3306/qingti_test?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
+    username:
+    password:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+```
+
+初版词库不依赖 datasource;该配置只为当前项目环境后续数据库接入预留。
+
+## 错误码
+
+在 `ability-center-kernel` 的 `ErrorCode` 增加:
+
+- `INVALID_GRADE`:400
+- `SURVEY_SESSION_NOT_FOUND`:404
+- `SURVEY_SESSION_EXPIRED`:410
+- `SURVEY_SESSION_FINISHED`:409
+- `SURVEY_LEVEL_NOT_FOUND`:404
+- `SURVEY_LEVEL_NOT_CURRENT`:409
+- `INVALID_SURVEY_WORD`:400
+- `SURVEY_WORD_GENERATION_FAILED`:500
+- `SURVEY_REPORT_GENERATION_FAILED`:500
+
+HTTP 层继续通过 `BusinessException + GlobalExceptionHandler` 返回统一 `BaseResponse`。
+
+## 测试设计
+
+### domain/application 单元测试
+
+至少覆盖:
+
+1. 年级映射。
+2. 第一关生成。
+3. 每关返回 12 个词。
+4. 同一 session 不重复出词。
+5. `unknownRate` 影响下一关中心词频。
+6. `currentWordWidth` 每关按 0.75 收敛。
+7. 最少 4 关、默认 5 关、最多 6 关结束规则。
+8. K 值估算。
+9. start use case。
+10. submit use case 继续下一关。
+11. submit use case 最终返回报告。
+12. session 查询恢复当前关或返回报告。
+13. 非本关词提交返回错误。
+14. 重复提交幂等。
+
+### infrastructure 测试
+
+覆盖:
+
+- MessagePack 字典文件可以加载。
+- 能按词频附近取词。
+- 能通过 `wordId` 查词。
+- 能通过 `meaningId` 查词。
+
+真实 `dict.data` 加载测试可单独隔离,避免影响核心算法测试速度。核心测试使用 fake provider。
+
+### runtime HTTP 测试
+
+使用 MockMvc/WebMvcTest 风格覆盖:
+
+- `POST /mini-program/vocabulary-survey/start`
+- `POST /mini-program/vocabulary-survey/levels/{levelId}/submit` 返回下一关。
+- `POST /mini-program/vocabulary-survey/levels/{levelId}/submit` 返回最终报告。
+- `GET /mini-program/vocabulary-survey/sessions/{sessionId}` 恢复当前关或返回报告。
+- 错误码映射。
+
+### 架构测试
+
+新增或扩展 ArchUnit 规则,保证:
+
+- `vocabulary-survey-domain` 不依赖 contracts。
+- `vocabulary-survey-domain` 不依赖 Jackson / MessagePack。
+- `vocabulary-survey-infrastructure` 不依赖 runtime adapter。
+
+## 验证命令
+
+实现完成后至少运行:
+
+```powershell
+mvn test
+```
+
+如果全量测试受现有 Playwright/PDF 测试影响,可补充运行目标模块:
+
+```powershell
+mvn -pl abilities/vocabulary-survey/domain test
+mvn -pl abilities/vocabulary-survey/application test
+mvn -pl abilities/vocabulary-survey/infrastructure test
+mvn -pl ability-center-runtime test
+```
+
+最终汇报必须说明真实验证结果;未跑或失败不得声称通过。
+
+## 风险和后续演进
+
+### 初版风险
+
+- session 为内存存储,服务重启后丢失。
+- `dict.data` MessagePack 结构需要通过真实文件验证 Java 反序列化字段名和类型。
+- 词汇量初版采用 `vocabularySize = K`,不是完整掌握度模型。
+- 当前项目未接登录态,`studentId` 暂由请求体传入。
+- 复制二进制词库到仓库后,仓库体积可能增加;词库更新需要替换文件并重新发版。
+
+### 后续演进
+
+- session、level、word 明细落库,支持服务重启恢复和多实例部署。
+- 接入真实登录态,从认证上下文获取学生 ID。
+- 将词库更新流程从复制文件升级为 OSS 下载、版本校验或配置化挂载。
+- 引入更完整的词汇量估算模型,使用实测词掌握度和未实测词估算掌握度聚合。
+- 根据真实小程序体验调整停止条件、反馈文案和报告文案。
+
+## 实施顺序
+
+1. 新增 Maven 模块和基础 package。
+2. 复制 `dict.data` 到 infrastructure resources。
+3. 新增 MessagePack 依赖和词库加载 adapter。
+4. 实现 domain 模型、年级策略、选词规划、K 估算和报告生成。
+5. 实现内存 session repository。
+6. 实现 application service。
+7. 实现 runtime controller 和配置。
+8. 新增错误码。
+9. 补齐单元测试、基础设施测试和接口测试。
+10. 运行验证命令并汇报结果。
+
+## Git 提交说明
+
+superpowers brainstorming 流程建议提交设计文档。但当前会话的上层约束是“未经明确授权不 commit”。用户尚未明确要求提交,因此本设计文档只写入工作区,不执行 git commit。