summaryrefslogtreecommitdiff
path: root/docs/maintenance.md
blob: 3a6323c371c474dca99399dcc829727f8dde8adc (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
# 模块维护说明(中文)

本文件面向维护者,给出 `src/` 各模块的**职责、关键数据结构、不变量、失败分支、单位与口径**,
并写死性能基准的**实测口径与数字**。历史批验流水(P16…P49 的逐单记录)在
[`docs/维护说明.md`](维护说明.md);本文件按**模块**组织,是补充而非替代。

> 适用范围:`LivingVillage.Kernel`(纯模拟)、`LivingVillage.Desktop`(渲染/交互/职业接线)、
> `LivingVillage.Headless`(无头批处理与性能探针)。不含测试工程内部结构。

---

## 0. 全局单位与口径(先读)

| 量 | 单位 / 取值 | 出处 |
|---|---|---|
| 模拟 tick | 1/60 秒(`ticksPerSecond = 60`) | `Sim.fs:246` |
| `dtSeconds` | `1/60`(tick 对应秒) | `Sim.fs:249` |
| 一个模拟日 | `ticksPerDay = 86,400 × 60 = 5,184,000` tick | `Sim.fs:248` |
| 瓦片 | `tilePixels = 32` 像素/格 | `Sim.fs:263` |
| 位置 | 像素(浮点 `Vec2`,世界坐标) | `Sim.fs` / `WorldBootstrap.fs` |
| 速度 | 像素/秒(avatar 160、npc 80) | `Sim.fs:264,266` |
| 需求 `Needs` | 0…100,`needsClamp` 每步夹取;每 tick 衰减 | `Sim.fs:398,308-311` |
| 钱 `Money` | `float32`,交易与衰减在 `Avatar.Mind.Needs.Money` | `Sim.fs:1140`、`PlayerTrade.fs` |
| 关系 | `Valence` 为 `float32`,按半衰期衰减;`|rel| > 0.5` 视为有关系 | `Sim.fs:295-296` |
| 职业 | 玩家侧侧车,**不进 `World`**;`Kind = Farmer|Fisher|Peddler|Scholar` | `Occupation.fs` |
| 存档 | v1/v2/v3 文本;**无职业 = v2**,与旧版逐字节一致 | `WorldSave.fs` |

**群组不变量(任何改动都不得破坏)**:
1. `Sim.step` 是纯函数:不改传入 `World`,返回新 `World`;数值路径**不读职业**。
2. 每 tick 结束 `Events` 清空(不承载历史);历史集合全部**有上界**(见 §4.3)。
3. 无职业世界 `WorldSave.save` 输出 `LV_WORLD_SAVE_V2`,与历史基线**逐字节一致**。
4. `--performance-baseline` 三次 `final_digest` 恒为
   `953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`(见 §4.2)。

---

## 1. Kernel(`src/LivingVillage.Kernel`)

### 1.1 `Sim.fs` —— 世界与数值模拟

- **职责**:定义世界结构、需求/关系/谣言/年鉴机制与 `Sim.step` 数值演化;是**唯一**的模拟真值来源。
- **关键数据结构**:
  - `World { Tick; Time; Rng; Avatar; NoHost; Npcs: Npc[]; Events; Rumors; RumorCount; RumorOldestDay; Annals }`(`Sim.fs:186`)。
    `RumorCount`/`RumorOldestDay` 是**派生镜像**(不参与序列化,读档由列表重建),用于把裁剪判定从全表扫描降为 O(1)。
  - `Avatar { Pos; Mind }`、`Npc { Id; Pos; Inventory: Map<ItemKind,int>; Mind }`、`Mind { Needs; Personality; Action; Target; ActionAge; EffectDone; HungerFlagged; Memory }`。
  - `ItemKind = Food | Fish | Spice | Scroll`;`NpcId` 为 int,玩家 `playerId = NpcId -1`。
  - `InteractionEvent`(`DialogueEvent`/`TradeEvent`…)、`RumorEvent`、`AnnalEntry`、`MemoryEvent`。
- **常量口径**(`Sim.fs:246-311`):`chatRangePx = 96`(对话范围)、`chatTicks = 300`;
  衰减 `hunger/energy/social/money = 0.0012/0.0010/0.0008/0.0006` per tick;
  谣言 `rumorFreshnessTicks = 3 天`、`rumorHalfLifeTicks = 1 天`、`rumorMinimumStrength = 0.125`。
- **上界**:`maxAnnalEntries = 128`(`appendAnnal` 头插 + `truncate`);`memoryCapacity = 64`/NPC;
  `rumorCapacity = 16384`、`rumorRetentionDays = 3`。
- **失败分支 / 边界**:地图 64×48 为默认,`configureBounds` 支持大图并按边界夹取 avatar/NPC
  (`LargeMapBoundsClampAvatarAndNpcs` 已锁);世界长期运行保持有限、`Time` 跟随 `Tick`。
- **算法取舍**:NPC 决策 `decideAction` 基于 `scoreAction(night, needs, personality)`,动作最短持续
  `minActionTicks = 600`;关系用半衰期衰减而非全局重算;谣言裁剪用派生镜像计数,避免 O(n) 扫描。
- **失败分支(注意)**:`Sim.step` 无 IO/异常路径;非法输入由调用方保证(测试覆盖纯函数性)。

### 1.2 `WorldSave.fs` —— 存档与版本兼容

- **职责**:`World` ↔ 文本的序列化/反序列化;承载职业 v3 侧车尾段。
- **格式**:`LV_WORLD_SAVE_V2`(无职业,含 `mapWidthTiles|mapHeightTiles` 后进入世界体);
  `LV_WORLD_SAVE_V3` 在 v2 的 bounds 后追加 `occupationKind|TaskToken|StoryStage`,世界体之后追加可选尾段。
- **v3 尾段(按序)**:`task-today <模板><状态><offeredTick><dueTick><targetNpc><targetTile>`、
  `backpack <n> (<item><qty>)…`、`streak <n>`(**仅 `Streak > 0` 时写**,故无 streak 的旧 v3 文本布局不变)。
  链式读取用 `reader.Remaining` 循环,`TokenReader` 仅 `Take`/`Remaining`。
- **关键函数**:`saveWith occupation world`(None→v2)、`save = saveWith None`、`load`、
  `loadFromFileWith`(返回 `World * Occupation.State option`)。
- **不变量**:
  - 无职业路径 `save` 与历史 v2 **逐字节一致**。
  - 新写出的 v3 文本 round-trip **逐字节稳定**(`saveWith (loadFromFileWith …)` 文本相同)。
- **失败分支**:`load null → Error`;未知/不支持的版本头 → `unsupported save format`;
  读到多余尾段 → `trailing save data`;背包数量 `< 0` → `invalid`;
  `loadFromFileWith` 捕获 `IOException` → `could not read save`;格式/溢出/参数异常统一转 `Error`。
- **旧档兼容**:v1/v2 读回 `occupation = None`(不吞档);v3 未知 kind → `None`;
  v3 无 `streak`/无 `backpack` 段 → 默认 `0` / 由 `profileOf` 初始清单补齐。

### 1.3 `Occupation.fs` —— 职业数据与纯规则

- **职责**:职业档案、每日任务状态机、设计口径报酬、连续天数、剧情线、对话/报价偏置;全部纯函数。
- **关键数据结构**:
  - `Profile { Kind; InitialMoney; InitialEnergy; InitialInventory }`(农夫 60/100/Food×12 等,`Occupation.fs:39`)。
  - `DailyTask { TemplateId; TargetNpc; TargetTile; OfferedTick; DueTick; State }`,`State = Offered|Active|Done|Failed`。
  - `TaskSignal = Dialogued | Traded | Purchased | ArrivedAt | Observed | NightAtWater`。
  - `State { Profile; TaskToken; Today; StoryStage; Backpack; Streak }`(玩家侧侧车,不进 `World`)。
- **任务口径**:`dailyTaskOf` 用 `hash(seed ⊕ occupationSeedOf kind, dayIndex) mod |pool|`(与 `Rng` splitmix 同源,禁 `System.Random`);
  `TargetNpc/TargetTile` 目前恒 `None`(通配,具体化留后续单)。
- **完成与报酬**:`satisfiesCompletion` 按模板匹配信号;`applySignalToState` 仅在 `Active` 命中时置 `Done`、
  `Streak+1`、并按 `rewardOf` 发放**设计文档写明的完成结果物品**:
  `NightCatch → Fish×2`、`DeliverGrain → Food×1`,**其余模板不发**(禁发明金额/新物品)。
- **连续天数**:`refreshState` 同日保留 `Streak`;跨日仅当上一日**紧邻且 `Done`** 才保留,漏完成(跳日)清零。
- **失败分支**:`Offered` 未接受不结算;`Done/Failed` 终态不可复活;无职业/无当日任务原样返回;
  非玩家事件(NPC↔NPC)不产生任务信号(`signalOfInteraction` 只认 `playerId`)。
- **报价偏置**:4 位定点整数基点,货郎 ±3%、书生 ±1%,钳 `[0.95,1.05]`;农夫/渔夫/无职业恒 1.0。
- **剧情线**:`Story.onDialogue` 每次玩家对话至多推进一段(`StoryStage 0→1→2→3`),结局由关系均值分档
  (≥0.3 热络 / [0,0.3) 平常 / <0 淡漠),纯函数、无随机。

### 1.4 `Rng.fs` / `RumorBench.fs` / `SimulationControl.fs`

- `Rng.fs`:splitmix64(`goldenGamma = 0x9E3779B97F4A7C15`);`nextUInt64`/`nextFloat32` 纯函数,
  有已知向量测试(`SplitMix64MatchesKnownVectors`)。
- `RumorBench.fs`:谣言工作集基准的纯逻辑。
- `SimulationControl.fs`:倍速/暂停控制(1x/2x/5x),`stepsPerFrameWithLegacy` 供 Game 取每帧步数。

---

## 2. Desktop(`src/LivingVillage.Desktop`)

### 2.1 `Game.fs` —— 主循环与装配

- **职责**:Game 主类:输入分发、世界推进、相机、绘制、存档、菜单/弹层、各 `LV_*` 证据钩子。
- **关键状态**:`world`、`m5View`(交互视图)、`menu`、`simulationControl`、`lastAvatarTile`(环境信号基线)、
  各 `pNN` 证据钩子游标(`p37…p55`)。
- **更新顺序**:菜单/弹层输入 → `m5Command` 选择 → 世界移动 `Sim.step` → `refreshOccupationToday`
  (P55 走 `refreshState`)→ 折叠环境信号(`ArrivedAt` 跨瓦片 / `NightAtWater` 夜+邻水)→ `refreshPrompt`。
- **不变量**:弹层开启时世界交互键被屏蔽(P44 输入隔离);职业选择不改变世界 seed;相机不越界。
- **失败分支**:存档 F6/F7 失败走 `MenuState.showLoadError`,不崩溃;无附近交互 → `no nearby interaction`。
- **证据钩子**:`LV_P36…P55_*` 环境变量触发无头截图/录制,**只读**、不改模拟语义。

### 2.2 `TaskRuntime.fs` —— 任务与奖励接线(P54/P55)

- **职责**:把 Kernel 任务状态机接到 Desktop 运行时:`accept`(Offered→Active)、
  `advanceWithEvent`/`advanceWithSignal`(经 `advanceTaskWithReward`/`applySignalToState`,含物品奖励)、
  `foldSignals`、`environmentSignals`、`completionText`。
- **不变**:全部纯函数;完成反馈为短中文(如 `任务完成:夜捕 +鱼×2`)。
- **失败分支**:无任务/终态/非命中信号 → 无变化、无反馈。

### 2.3 `Interaction.fs` —— M5 交互视图与命令

- **职责**:`M5View`/`M5Command`、面板(世界/任务/对话/观察/需求/年鉴)、`InteractionResolver`(就近交互解析)、
  `statusLabel`/`statusText`(中文短反馈)、`refreshPrompt`、`panelLines`。
- **命令**:`Interact`、`Intent1..6`、`BuyFromTarget`、`SellToTarget`、`AcceptTask`、`Observe`、`ToggleNeeds`、
  `ShowChronicle`、`ShowTaskPanel`、`ClosePanel`;`commandAllowed` 按面板门控。
- **不变量 / 失败分支**:`AcceptTask` 仅 `TaskPanel`;`Buy/Sell` 仅 `DialoguePanel`;
  无效选项 → `invalid dialogue option`;无菜单 → `no dialogue menu`;无目标 → `no nearby interaction`。
- **单位口径**:状态字符串前缀决定 HUD 文案(`trade ok|`、`trade fail|`、`task|`…)。

### 2.4 `PlayerTrade.fs` —— 玩家买卖通道(P52/P55)

- **职责**:玩家买/卖经 `Sim.quotePrice` → `Occupation.biasedQuote`;钱写 `Avatar.Mind.Needs.Money`,
  物品写 `Occupation.State.Backpack`;追加 `TradeEvent`+`TradeAnnal` 与记忆;顺带推进当日任务。
- **镜像技巧**:玩家不是 `Npcs` 成员,报价时把玩家侧临时镜像成 `Npc` 放进**报价世界副本**,不写回真实世界。
- **关键类型**:`Outcome { World; Occupation; Succeeded; UnitPrice; Reward; Message }`。
- **失败分支(中文短反馈)**:`未选择职业`、`找不到对方`、`数量必须大于零`、`库存不足`、`资金不足`、
  `背包不足`、`对方资金不足`、`背包里没有可卖物品`。
- **不变量**:只动 Decimal 侧车与钱,不动 `Sim.step`/地图/seed;背包归零移除、新物品追加尾部(稳定序列化)。

### 2.5 `WorldBootstrap.fs` / `MenuState.fs`

- `WorldBootstrap`:`occupationStateFor`(新档当日任务)、`refreshOccupationToday`(跨日,P55 走 `refreshState`)、
  `appendIdentityAnnal`、`initialWorldWithPlacement/WithOccupation/InMap`。开局把 `InitialMoney/Energy` 写入 avatar。
  失败分支:`count` 越界 → `invalidArg`。
- `MenuState`:菜单页(主菜单/设置/职业选择/暂停帮助)、`occupationOptions`(含「暂不选择」=`None`)、
  `occupationLabel`、`StartNewGameWith`、导航与错误提示。

### 2.6 渲染/美术模块(纯函数优先)

| 模块 | 职责 | 关键不变量 |
|---|---|---|
| `VillageArt.fs` | 图集取景、NPC 视觉变体/交易/行走节奏与帧 | `npcVisualVariant`/`characterFrameFor` 等为 (id/tick) 纯函数 |
| `MenuAmbience.fs` | P46 水面涟漪/灯笼呼吸/双层云影 | 帧计数纯函数,32 tick 边界连续、无随机 |
| `SceneDetail.fs` | 河道倒影、岸边芦苇、漂浮元素、灯笼光源的**纯数据** | `isWater` 边界检查;同 (tick,map) 可复现 |
| `SceneDetailRender.fs` | 把 `SceneDetail` 结果落成画面 | 只读,不改数据层 |
| `M6Presentation.fs` | 昼夜时段/色温/tint/月光/灯笼光晕 | `profileAtTick`、`lightingAtTick` 纯函数 |
| `HudLayout.fs` | 顶部窄条 HUD 布局/配色 + hotbar 输入门控 | 确定性布局,不整屏纯色/大黑块 |
| `TitleScreen.fs` / `LaunchScreen.fs` | 标题页/启动画面几何与帧动画 | 帧计数纯函数,可跳过 |
| `DialogOverlay.fs` | 对话/文字弹层模型(断行/分页/隔离) | 纯模型,绘制在 Game |
| `CharacterArt.fs` / `FloaterArt.fs` | 原创点阵角色/漂浮元素图集生成 | cell 布局固定(见文件头),无外部素材 |
| `ChineseText.fs` / `CjkGlyphAtlas.fs` | 中文文案与 CJK 图集覆盖 | `requiredUiLabels` 全覆盖,无缺字方框 |
| `VillagePresentation.fs` | 原型场景几何、进门/出门状态 | `enterHome`/`exitHome` 返回 `Result`,失败不崩 |
| `MapGen.fs` / `ProceduralMap.fs` | 确定性地图生成(512×384)与旧图激活 | 同 seed 逐字节一致;P48 digest 钉值 |
| `PerformanceSummary.fs` | FPS 样本均值/最低/最高 | 纯函数,warmup 可剔除 |
| `SampleScript.fs` | 自动演示脚本状态机 | 有超时上限,确定性 |

---

## 3. Headless(`src/LivingVillage.Headless`)

### 3.1 `Program.fs`

- **职责**:无头入口。命令:`--days/--seed/--npc`(+ `--dump-relations/--dump-rumors/--replay-rumors`)、
  `--m5-smoke`、`--m6a-smoke`、`--batch K D`、`--performance-baseline`、`--cost-probe [D1,D2,…]`、
  `--profile-long-run [days] [sampleEveryDays] [npcs] [seed]`、`--rumor-bench`。
- **`--profile-long-run`**:逐模拟日推进,每 `sampleEveryDays` 天流式打印并 flush:
  `GC.GetTotalMemory(true)`(GC live set)、`Process.WorkingSet64`(OS RSS)、
  `Events/Rumors/Annals/Memory` 条目数与保守估算字节、首末点 world digest。
  **只读**,不改模拟行为;退出码 0 当且仅当 `world.Tick = days × ticksPerDay`。
- **估算模型**(仅数量级参考,ground truth 是 GC/RSS):`InteractionEvent≈48B`、`RumorEvent≈112B`、
  `AnnalEntry≈96B + Summary×2`、`MemoryEvent≈72B`。

### 3.2 `PerformanceProbe.fs`

- **职责**:固定配置的吞吐/分配/GC 测量与快照 digest。
- **配置**:`defaultConfiguration = { Seed=42; NpcCount=4; WarmupTicks=120; MeasureTicks=6000; Repetitions=3 }`,
  输入为固定八步循环(不依赖时钟/全局随机)。
- **口径**:计时仅覆盖预热后的 `MeasureTicks` 次 `Sim.step`;分配量为测量线程
  `GC.GetAllocatedBytesForCurrentThread` 增量;GC 次数为前后进程计数器差;需**单进程单测量线程**。
- **digest**:`worldDigestOfText` 剥版本头与 bounds,把 v1/v2/v3 归一到单一规范体
  `SHA256("LV_WORLD_SAVE_V1" + body)`;`WorldDigestOfText` 为公开包装。
- **失败分支**:非法配置 → `performance_baseline=INVALID` 且返回 2;重复样本 digest 不一致 →
  `performance_determinism=FAIL`、返回 1。

---

## 4. 性能基准(实测口径与数字)

> 原则:**测量再宣称**。以下为本机 linux / .NET 8 的实测,非硬性门限;compare 时须连同运行时与配置记录。

### 4.1 吞吐 / 分配 / GC(`--performance-baseline`)

命令:`dotnet run -c Release --project src/LivingVillage.Headless -- --performance-baseline`

配置:`seed=42 npc_count=4 warmup_ticks=120 measure_ticks=6000 repetitions=3`,8 方向循环输入。
实测(单进程单线程,本机一次运行,数值随机器/负载浮动):

- `elapsed_seconds ≈ 0.006–0.014`,`ticks_per_second ≈ 0.4M–1.0M`(噪声大,仅作量级)。
- `allocated_bytes ≈ 3,032,104–3,032,344`(**≈3.03 MB / 6000 ticks**)——稳定。
- `gen0 = gen1 = gen2 = 0`(测量区间无 GC)。
- `final_tick = 6120`,`runtime = .NET 8.0.x`。

### 4.2 digest 钉值与确定性验证法

- 钉值:`final_digest = 953775FAEB2FDDE97289491AA260BD8D390C571E48A7A13AD2CB6FB7124F7F6C`。
- 验证法:**连续运行 3 次** `--performance-baseline`,逐次比对 `performance_sample repetition=1/2/3` 的
  `final_digest`;三次全等即输出 `performance_determinism=PASS`(PASS 由探针自动判定,非人工)。
- 任何触碰 `Sim.step` 数值路径/`World` 结构/`Rng` 输入的改动都会改变该 digest → 视为**回归**;
  职业/背包/任务/streak 等侧车改动**不得**改变它(`WorldSave.save` 走 `None`,digest 不变)。
- 单测级锁:`PerformanceTests.FixedSeedAndInputSequenceProduceTheSameWorldDigest`、
  `MeasurementReportsConfiguredTicksAndRuntimeCounters`、`InvalidConfigurationReportsAUsefulFailure`。

### 4.3 长局内存(`--profile-long-run`,P49)

命令:`dotnet run -c Release --project src/LivingVillage.Headless -- --profile-long-run 40 4 30 42`
(1 世界 × 40 天 = 207,360,000 tick;证据 `artifacts/perf-independent/20260928T134226/`)。

40 天曲线(11 采样点,每 4 天):

| day | GC live MB | WorkingSet MB | Events | Rumors | Annals | Mind.Memory |
|---:|---:|---:|---:|---:|---:|---:|
| 0 | 0.09 | 38.75 | 0 | 0 | 0 | 0 |
| 4 | 1.43 | 62.19 | 0 | 7843 | 128 | 1920 |
| 12 | 1.44 | 66.68 | 0 | 7727 | 128 | 1920 |
| 24 | 1.40 | 68.26 | 0 | 7788 | 128 | 1920 |
| 40 | 1.42 | 68.35 | 0 | 7892 | 128 | 1920 |

**结论**:`Events` 恒 0(每 tick 清空);`Annals` 恒 128(capacity);`Mind.Memory` 恒 1920(64×30);
`Rumors` 稳态 ~7.7k–8.0k(3 天 retention 绑定);GC live 0.09→~1.4 MB 后平稳、
WorkingSet 38.75→~68 MB 后平稳(一次性预热,**无持续增长**)→ **无长局内存泄漏,按数据不修**。
集合上界见 §1.1;多世界为独立实例,内存线性叠加,无跨世界共享增长态。

### 4.4 数组快照不变 / 地图 digest

- 世界快照不变:`DeterminismTests.StepIsPureAndDoesNotMutateItsInput`(`Sim.step` 不改传入世界)、
  `SameSeedSameInputSequenceTracesAreTickByTickEqual`、`NpcDecisionTraceIsTickByTickDeterministic`。
- 存档快照:`WorldSave` round-trip 文本稳定(`P55`/`P52`/`P51` 测试)。
- 地图生成:`P48MapDigestTests.SameSeed512DigestIsStableAcrossRuns` / `Pinned512DigestLocksGeneratorOutput`
  (同 seed 稳定、异 seed 发散、跨尺寸发散、512 钉值)。

---

## 5. 复现入口(维护者速查)

```bash
# 构建
dotnet build LivingVillage.sln -c Release

# 全量测试(先 build,再用 --no-build 复跑须一致)
dotnet test src/LivingVillage.Kernel.Tests  -c Release --no-build   # 119/119
dotnet test src/LivingVillage.Desktop.Tests -c Release --no-build   # 282+(随切片增长)

# 确定性钉值(三次 final_digest 全等 + performance_determinism=PASS)
for i in 1 2 3; do dotnet run -c Release --project src/LivingVillage.Headless -- --performance-baseline; done

# 长局内存曲线
dotnet run -c Release --project src/LivingVillage.Headless -- --profile-long-run 40 4 30 42
```

证据钩子(只读截图/录制,如 `LV_P54_SHOT=1`、`LV_P55_SHOT=1`)与对应
`scripts/analyze-pNN.py` 的用法见各切片提交与 `docs/evidence/`。