# 设计说明

这份文档解释 moonnet-lab 为什么长成现在这样。评审关心的"技术路线是否讲得清楚"，以及后来接手的人关心的"为什么不能用另一种更简单的写法"，都写在这里。

## 目标

让网络实验可复现。具体到可检验的承诺是：**给定场景文件和随机种子，两次运行产生完全相同的事件序列、随机数序列和指标。**

非目标同样重要：

- 不做通用离散事件仿真框架。生态里已经有 moonsim、moondes 等引擎，本项目只在需要的地方实现自己的一小块内核，并把边界写清楚。
- 不解析真实抓包文件。pcap 解析已有成熟实现，本项目构建的是行为模型，不是取证工具。
- 不追求大规模。设计目标是几百到几千个包的场景能在毫秒级跑完，让参数扫描成为常规操作而不是批处理任务。

## 时间模型

虚拟时间是一个 `Int64`，单位是**皮秒**。

选择整数而不是浮点秒，是因为浮点会让"同一场景两次运行结果一致"变成一件需要论证的事：不同后端、不同优化等级下，`0.1 + 0.2` 的舍入都可能有细微差别，而这些差别一旦进入事件排序，就会放大成完全不同的结果。整数没有这个问题。

皮秒这个量级是被两端夹出来的：

- 下界由高速链路决定。64 字节的帧在 100 Gbps 链路上占用 5.12 纳秒，用纳秒做单位会直接损失精度。
- 上界由 `Int64` 决定。`Int64::max` 皮秒约等于 106 天，而一次仿真通常只跑几秒到几分钟。

链路的串行化时间用 `Time::serialize(bytes, bps)` 计算，全部是整数乘除，只在最后一步向下取整到皮秒。这是整个仿真里唯一的舍入动作，并且它作用于输入而不是中间状态。

## 事件顺序

事件队列是一个二叉最小堆，比较键是 `(时间, 到达序号)`。

时间相同时用序号打破平局，这条规则看起来琐碎，实际上是确定性的关键。如果只用时间做键，两个安排在同一皮秒的事件执行的先后就取决于堆内部的交换顺序——那是实现细节，任何一次重构都可能改变它，进而改变整个实验结果。有了序号，同一皮秒内的执行顺序就是"谁先被调度谁先跑"，一个可以被阅读和推理的语义。

堆是自己写的而不是用标准库的优先队列，原因只有一个：这里的比较键需要同时考虑 `Time` 和序号，而把两者编码成一个可比较的结构比直接写 60 行堆代码更绕。堆的实现有独立测试，包括一个 500 个逆序插入的事件批次的顺序断言。

调度到过去的时间点会直接 `abort`，并在消息里带上事件标签。这类错误几乎总是模型 bug（比如忘记把延迟算进去），静默容忍只会让结果看起来合理但无法在真实网络上发生。

## 随机性

随机源是 SplitMix64 做种子扩展、xoshiro256** 做输出流，两者都自己实现，且参考向量在测试里钉死。

之所以不用标准库的随机数：实验结论需要长期可复现。标准库有权在某次版本更新里改进算法或种子处理，而本项目不能接受"升级工具链后历史实验的数字变了"。把算法冻结在包里，就等于把可复现性变成一条可执行的测试断言。

`Rng::fork(label)` 用于给每条链路、每条流派生独立的随机流。它做的是 `Rng::new(parent.next_u64() ^ fnv1a64(label))`：父流贡献熵，标签决定身份。这样做的效果是，场景里加一条新的、互不相干的流，不会改变其他流的丢包序列——否则每加一条流，所有历史结果都要重新解释。

## 链路与队列

链路模型是三段式的：

1. 包进入有界队列。队列怎么决定收不收，由"队列管理策略"决定，见下。
2. 链路一次只发送一个包，发送时长等于 `size × 8 / bandwidth`。当两个包在同一皮秒被交给链路时，第二个包会发现链路忙而排队，由"链路空闲"事件把它唤醒。这个判断如果少了，模型会凭空多出带宽。
3. 发送结束后加上传播延迟和抖动，到达对端。

队列的存储是环形缓冲，构造时只分配 64 个槽位，按需翻倍到配置上限。这样"按字节限制、不限制包数"的队列不会一上来就按理论最大值分配内存。事件处理过程中不分配内存是一个刻意的约束：让时间行为与宿主内存管理彻底解耦。

队列的入队结果是 `Admission` 枚举而不是 `Bool`。丢包原因是报告里必须出现的信息——"丢了 3 个包"和"因为字节上限丢了 3 个包"指向完全不同的调参方向。

**队列管理策略**是三个值组成的枚举，而不是一个可插拔接口，这是刻意的：

```text
DropTail                     满了才丢
Red(下限, 上限, 最大概率)      按"到达时更新的平均占用"概率提前丢
Codel(目标, 间隔)            按"队头已经等了多久"提前丢
```

前两个可以直接叫"策略"，第三个不能：它需要缓冲区自己的状态（队头何时入队、上次丢弃发生在什么时候、已经连丢了几次、下一次最早什么时候可以再丢）。一个只能在 `enqueue` 时刻做一次判断、返回值是布尔量的回调装不下这些状态，硬塞进去就得把状态存在调用方那里——那时接口描述的不再是策略，而是"策略的持久化位置"。所以策略是枚举，状态在队列里。

反过来说，拥塞控制是接口而不是枚举，因为**算法的数量会增长且互不知情**；队列管理只有这三种被广泛部署的做法，且每一种的状态形状不同。两个决定来自同一条判据：让差异落在数据上，而不是落在需要改动的代码行数上。

CoDel 的控制律在这里有一个刻意的偏离：RFC 8289 的参考实现是在**出队**时评估队头等待，本项目在**入队**时评估。信号（队头已经等了多久）与响应（一旦这个等待持续超过 `interval`，每个 `interval` 丢一个，间隔按 `interval / √丢弃次数` 收缩）与 RFC 一致；在入队时评估是为了不出现"链路先收下这个包，随后又放弃它"——那会让丢包计数与链路已经消耗的发送时间对不上。

`max_sojourn`（最坏排队延迟）只在出队时记录，这是为了让三种策略下的数字可比：它回答"真正发出去的包最多等了多久"。被拒绝的包等了多久属于那个包自己的故事，不是这条队列给通过流量的延迟。这一条之所以要写下来，是因为 CoDel 的内部状态里恰好有一个现成的"队头等待"，顺手记下来会让它的数字比 drop-tail 大，从而在对比里制造一个不存在的问题。

## 分层与依赖

`src/sim` 不认识网络，`src/net` 不认识 TCP。数据包是 `Packet[T]`，`T` 由上层决定：链路层负责调度和统计，TCP 层把序号、窗口、标志位挂在同一个包上。这样链路层可以独立测试（20 个包穿过带抖动的链路，两次运行结果一致），TCP 层也可以在不需要真实链路的情况下单测。

CLI 是薄的一层：每个子命令对应一次实验，所有数字都来自库里被测试覆盖的函数，不在命令行里另算一遍。

## 机制与策略的边界

拥塞控制被拆成一个接口，而不是写死在连接里。连接负责它无法回避的机制——序号、定时器、重传、流量控制；`CongestionControl` 只回答两个问题：现在允许多少字节在途，以及收到确认、快速重传、重复确认、部分确认、离开恢复、超时这六件事发生时窗口怎么变。

这条边界是这样切出来的：**换一个算法不应该需要改动任何一行机制代码**。CUBIC 和 Vegas 与 Reno 的差别全部落在"同样四个事件，反应不同"上——CUBIC 用三次函数代替线性增长，Vegas 用延迟而不是丢包当作信号。接口里因此没有泄漏任何算法特有的概念。

代价是接口有七个方法，其中三个（快速重传、重复确认、部分确认）只对基于丢包的算法有意义。延迟类算法会实现成空操作——这是可接受的，因为一个算法可以选择忽略一个信号，但不能要求机制为它改变形状。

一个容易搞错的地方：**快速重传的三次重复 ACK 触发的是机制，窗口减半是策略**。前者在连接里，后者在算法里，两者通过 `on_fast_retransmit(flight_size, mss)` 连接，其中 `flight_size` 是"事件发生时真正在途的字节数"，而不是算法以为的窗口大小。传真实在途量而不是窗口值，是因为路径上发生的事和算法的记忆可能不一致——比如窗口刚刚增长过，而第三个重复 ACK 还没到。

## 已知边界

- 单包最大约 1.1 MB，这是 `bytes × 8 × 10¹²` 不溢出 `Int64` 的边界，超出需要分片。
- 抖动会被裁剪到不超过传播延迟，避免出现负的到达间隔。
- 队列管理有三档：drop-tail、RED、CoDel。CoDel 的目标（5 毫秒）与间隔（100 毫秒）用的是 RFC 8289 的推荐值，没有做成场景参数——做成参数会让"换个场景"和"换个策略调参"混在一起。队列只有单个出队口，没有加权或多队列形态。
- **被取消的定时器事件仍然留在队列里。** 重传定时器用代际号做惰性失效：重新设置定时器只是把代际号加一，旧事件到期时自己发现已经过期并直接返回。这样做不需要从堆里删除任意元素，代价是队列里会有一些立刻返回的空事件。`Sim::run` 会把它们跑完，于是时钟会停在最后一个过期事件上——所以"跑到最后一个字节送达"这类测量必须用 `Sim::step_until`，不能用 `sim.run()` 之后读时钟。这个区别不是学术性的：它曾经让演示里的传输时间从 330 毫秒变成 1.3 秒。
- **快速重传会交给拥塞控制。** RFC 5681 把快速重传与快速恢复绑在一起：重传的同时要把拥塞窗口减半，并在恢复期用重复 ACK 膨胀窗口。这两件事现在都由算法实现：连接在触发重传时调用 `on_fast_retransmit(flight_size, mss)`，Reno 减半、CUBIC 乘 0.7 并进入恢复膨胀。缺的是重排序检测，见下一条。
- **没有重排序检测。** 三个重复 ACK 就触发重传，没有考虑 ACK 只是因为路径重排序而乱序到达的情况。真实协议用 DSACK 或 F-RTO 识别并撤销这类伪重传。

## 性能：三处平方复杂度

## 依赖：库零依赖，工具用官方扩展库

## 丢包是数据的属性，不是发送顺序的属性

最初的链路按每次发送抽一次签。这对单条连接的统计特性没问题，但让"两个算法对比"变成一个受污染的实验：同一种子只保证同样的随机源，而两个算法发送顺序不同、发送数量不同，于是它们遇到的是两串不同的丢包。只要被比较的两个东西看到的世界不一样，比较就没有意义。

现在每个包带一个身份：起点是 TCP 的序号，加上"这是第几次发送"。链路用 `mix64(链路密钥, 身份)` 得到决定，于是：

- 同一个包在同一条链路上永远遇到同样的命运，不管中间还传了别的什么
- 重传带着新身份，所以被丢掉的包仍然救得回来——身份里必须包含发送次数，否则一次被判定丢失就等于永远丢失
- 链路密钥由种子和链路名共同决定，所以同名同参数的两条链路不会丢同样的包

代价是"随机性"从按时间抽样变成了按数据抽样。对单条按序发送的连接，这两种模型在统计上没有区别；差别只出现在同一个包被重复发送时（这时我们**希望**它们是两次独立抽签），以及多条流交织时（这时身份里的流号会让每条流有各自的丢包模式，而不是共用一条时间轴上的抽签序列——多流支持时需要把流号加进身份）。

ACK 是例外：同一个 ACK 会被反复发送，它没有稳定身份，因此退回按传输顺序抽签。这留下了一个不受控的角落（ACK 的丢失模式仍然依赖发送顺序），值得在实验报告里说明而不是假装不存在。

仿真部分（`src/sim`、`src/net`、`src/tcp`、`src/json`、`src/lab`）不依赖项目之外的任何东西，全部只用标准库。这不是洁癖，而是可复现的一部分：一个实验平台如果会因为上游包的一次小版本更新而改变数值，它的结论就不可信。

唯一的例外是 `cmd/moonnet` 里的文件读写。MoonBit 标准库不含文件系统（它要能跑在 wasm 里），读文件必须用官方扩展库 `moonbitlang/x`。所以边界划在命令行这一层：**库接受字符串，工具负责把文件变成字符串**。这样"实验是文件"成立，而仿真本身仍然可以在任何地方、以任何方式被嵌入使用。

顺带一个必须记住的坑：MoonBit 的 `String` 内部是 UTF-16，而 `String::to_bytes`、`@encoding/utf8.encode` 产出的是 UTF-8 字节。两者之间的转换必须走 UTF-8 编解码器——用 `Bytes::to_unchecked_string` 之类的函数直接读字节，会把 `"123"` 变成 `"㈱"`：三个字节被当成一个半 UTF-16 码元。JSON 解析器里每一处字节到字符串的转换都踩过这个坑，现在都走 `@utf8.decode_lossy`。

前四个里程碑的测试数据量都很小，最大的场景不过几百个报文，所以任何实现方式都跑得动。做 CUBIC 时需要一条带宽延迟积 1.25 MB、传输 20 MB 的长肥链路，之前被掩盖的三处平方复杂度同时暴露出来——一次 4 MB 的传输需要 14 秒，而事件总数只有两万。

**第一处：接收端把所有收到的字节累加进一个缓冲区。** 每次追加都要复制整个已积累的缓冲区，于是 4 MB 的传输变成 8 GB 的 memcpy。协议不需要记住自己交付过什么，只有测试需要。改成默认只记字节计数、由调用方显式开启捕获。这一处修掉之后，同一场景从 13.9 秒降到 53 毫秒，缩放也从平方变成线性。

**第二处：每个 ACK 都遍历整个在途队列。** 在途队列按序号有序，被确认的永远是最前面的一段，所以只需要一个头指针和一次摊销的压缩，就把每个 ACK 的代价从"窗口大小"降到常数。这段代码原来的注释还写着"扫描整个队列是对的"——它确实是对的，只是在一个窗口会长到几千个报文的路径上不对。

**第三处：每次发送都重新计算通告窗口。** 通告窗口是接收缓冲的剩余空间，而原来的实现每次都把乱序缓冲区求和一遍。同样改成维护计数。

这三处的共同点是：**它们在功能测试里完全看不出来，只有把数据量放大一个数量级才会现形。** 这也解释了为什么"一次只做一个功能"是有价值的——每个功能都会带来新的场景，而新场景会把上一层的隐藏问题顶出来。

顺带一条同类的教训：`sim.note("send " + segment.label())` 这样的调用，即使日志没开也会先把字符串拼好再传进函数。几万个报文下来，光是拼日志字符串就成了主要开销。现在的约定是：可能产生大量调用的日志点先问 `sim.is_recording()`，再决定要不要构造字符串。

## 开发方式：AI 工具做了什么、没做什么

本项目大量使用 AI 编程工具，分工是明确的：

- **交给 AI**：模块实现、测试用例、文档草稿，以及大量机械性重构（例如把接收端缓冲区从"累加字节"改成计数器）。
- **留下把关**：算法保真度、实验设计，以及**判断哪些数字不可信**。CUBIC 的公式与常数是逐条对照 RFC 9438 原文写的；每一个结论都要能被独立复算。

上面那三处性能陷阱和 UTF-16 那一处，都是测试或性能测量先发现、人再判断该怎么改的；两次"结论被自己推翻"（见 roadmap 的 M8）也是人不接受一个数字就写进文档的结果。结论：AI 让实现速度提升一个数量级，但**实验的可信度不能外包**——这个项目里最花时间的部分不是写代码，而是确认代码产出的数字是对的。
