> ## Documentation Index
> Fetch the complete documentation index at: https://docs.clousight.com/llms.txt
> Use this file to discover all available pages before exploring further.

# 结果查看器

> 基于结果目录的本地 Web 查看器——运行过程中实时观看，运行结束后从看板、套件对比、记录一路读到轨迹。

`csbench serve` 在结果目录之上启动一个小型本地 Web 服务器，并提供一个内嵌的单页查看器。它在一次运行**正在进行时**把它展示出来，帮你读懂已经跑完的那些运行，并且——只有在你明确要求时——让你编辑目标配置、发起运行。

**只读是默认值，而这正是设计本身。** 不加 `--allow-write` 时，所有写路由返回 405 并说出该加哪个 flag，于是「这里不让干」永远不会被读成「它坏了」。唯一的例外早于这个开关：给一次仍在进行的运行创建一个零字节的 `cancel` 标记，见下文“中止一次运行”。

**已封存的记录永远不可写**，开不开 flag 都一样。每条记录都带一个覆盖其测量值的 `record_digest`；一个编辑入口会让「这个结果可被复核」当场作废，而整个工具就立在这句承诺上。见下文“写能力”。

## 用法

```bash theme={null}
csbench serve                          # 在 http://127.0.0.1:8787 上提供 ./results
csbench serve --results out/results    # 指定其他结果目录
csbench serve --port 0                 # 临时端口；打印的 URL 显示实际绑定的端口
csbench serve --host 0.0.0.0           # 绑定非 localhost —— 查看器没有鉴权，切勿暴露到公网
csbench serve --configs configs        # 目标配置放在哪（默认 ./configs）
csbench serve --allow-write            # 额外开放：编辑目标、发起运行。默认关闭。
```

打印出来的那行会点明当前姿态——`(results: results, read-only)` 或 `read+write`——因为这就是这个服务器全部的安全论证，翻回滚动条的人应该一眼看出跑的是哪一个。

该命令会打印实际绑定的地址（配合 `--port 0` 很有用），并持续运行直到按 Ctrl-C。

页头是五个段——**基准**、**云平台**、**运行**、**观测**、**配置**——外加**语言切换器**（EN | 中文；选择持久化到 `localStorage`，默认跟随浏览器语言）、**工程师视图**开关和**主题**开关（在手动切换前跟随 `prefers-color-scheme`，切换后持久化）。只有真的有运行在进行时，实时入口才会带一个计数徽标；不存在常驻的“0”。

## 实时观看一次运行

在一个终端里起一次运行，在另一个终端里 `csbench serve`。这次运行会自己出现——查看器每隔几秒轮询一次 `/api/progress`，只要有运行在飞行中，看板上方就会出现一条实时条。

* **`#/live`**——同时在飞行中的全部运行。如果恰好只有一个，页面直接转到那次运行。
* **`#/live/:run_id`**——单次运行，由一条到 `/api/progress/<run_id>/stream` 的 SSE 连接驱动。

运行页由五块面板组成：

* **页头**——测评项、平台、状态、运行期间每秒跳动一次的计时器，以及 **Stop run** 按钮。
* **进度**——用人话写的当前阶段（和记录页共用同一套阶段词表），以及当前 phase 的 `12 / 99 query` 和进度条。剩余时间估算标注为 *est.*，并且**在至少完成 3 个单位之前一律不显示**（`web/src/lib/eta.ts`）：一个自信但错误的“还剩 3 秒”比诚实的空白代价更高。
* **执行过程（实时）**——到目前为止这次运行的瀑布图，数据来自 `step` 事件而非 span。（它现在是独立的渲染器：已封存的轨迹页已改成“读结果”一节描述的那棵可缩放的树，不再画这张瀑布图。）实时 step 用**名字**指向父节点（套件知道自己把外层 phase 叫什么，却不知道 tracer 之后会给它铸什么 span id），所以嵌套是按名字解析的。
* **目前的数字**——**在浏览器里**用已完成的 step 算出来的：完成了多少 step、step 耗时的中位数与 p99、失败的 step 数、目前最慢的 step。
* **日志**——这次运行的 `clousight_bench` 日志尾巴，可按级别过滤。自动滚动是“粘底”的：一旦你往上滚，它就不再把你拽回最新一行。

### “初步”意味着它不是测量值

*目前的数字*里的每块数字都带一个**初步**标记，面板本身也直说这些数从哪来。这是一条契约，不是谦虚：

* **测量值**由 `Evaluator` 离线地、在运行结束之后、基于已封存的证据算出——它是落进记录、并被 `record_digest` 覆盖的那个东西；
* 实时数字是浏览器对“目前收到的事件”做的一个滞后算术，而这条流是被明确允许丢事件的。

任何被计分的东西都不会从 `sample` 事件推导出来。实时页的截图就是一次未完成运行的截图，UI 的构造方式让读者无法把它误当成结论。

### 为什么被测量的窗口显示的是不确定进度条

一个 phase 可以声明：我知道自己有多大，但我没法在里面逐步上报——
`progress.phase("Throughput Test", total=396, unit="query", reports_progress=False)`。
查看器于是画一条不确定进度条，并写上一句\*“measured window 内不上报中间进度”\*，而不是画一条读起来像卡死的 0% 进度条。

原因在套件里，不在 UI。在 TPC 官方模式下，Throughput 测试自己的墙钟**就是**那个指标（`Throughput@Size`，即 `T_TT` 项），在窗口内写进度文件等于把查看器的 I/O 写进了这个数。所以套件在窗口内什么都不上报，等窗口关闭后，再用它自己已经测好的区间把每条流、每条查询的 step 回放出来——同样的数、同样的布局，只是晚了几秒。套件作者需要遵守的规则见[添加一个测评](/docs/zh/adding-a-suite)的“上报进度”一节。

### 中止一次运行

Stop 按钮是查看器唯一的写操作控件，并且刻意做成两步：先待命，再确认。按下后会向 `/api/progress/<run_id>/cancel` 发一个 `POST`，它在这次运行的进度目录里创建一个零字节的 `cancel` 文件。HTTP 上再没有别的事发生。

运行按自己的节奏来取它——编排器在每个阶段边界检查一次，接好线的套件在自己的循环里轮询 `should_cancel()`（一次带缓存的 `stat`，最多每秒 4 次）。一旦命中，运行抛出 `RunCancelled`；它是 `KeyboardInterrupt` 的子类，因此中止走的是 Ctrl-C 早就走过的那条路：

* **teardown 照常执行**——已开通的资源不会被遗弃，也不会继续计费；
* 会持久化一条 **`interrupted` 记录**，带上已经完成的那些阶段；
* 实时页切换成交接卡片，指向那条记录。

一旦请求了中止，按钮保持禁用并显示\*“正在停止——teardown 执行中”\*。何时能停由运行自己决定；假装浏览器说了算是一句谎话。

### 运行结束之后

页面不会变白，也不会 404。进度目录会存活 `TERMINAL_GRACE_S`（默认 120 秒），正是为了让订阅者仍能读到终态的 `done` 事件和它携带的已封存记录路径；页面随即切换成指向 `#/record/<run_id>` 的交接卡片。

如果有事件为守住流的上限而被丢弃，页面会明说，并说明丢的是哪几类——以及已封存的记录不受影响。

## 阅读结果

四个页面，按问题收敛的顺序排列：

| 页面 | 路由 | 它回答的问题 |
| - | - | - |
| 看板 | `#/` | 每个域、每个测评项各测过些什么？ |
| 套件对比 | `#/suite/:domain/:suite_id` | 在**这一项**上哪个平台更好？ |
| 记录 | `#/record/:run_id` | 这一次运行得出了什么，可信吗？ |
| 轨迹 | `#/record/:run_id/trace` | 它内部到底发生了什么？ |

* **看板**把每条记录先按域、再按测评项归组，展示每个测评项的平台数、运行数，以及最新一次运行的头条指标。域和套件按最近活跃时间排序，每张域卡片先展示 5 个测评项、其余折叠：一个积累了大量已退役测评项的结果目录，不该把你今天刚跑的东西挤出第一屏。没有同时给出域名和套件名的记录（schema 0.1 时代的运行会摘要成空字符串）**不会**被折进来——一张没有名字也没有值的卡片比缺席更糟——它们仍可在 `#/runs` 看到。
* **套件对比**以真正用来做选型决策的那张表开头：每个平台的最新一次运行，每个指标一列，按词表顺序（头条指标在前）而非字母序排列。只有某个平台的**最新**运行确实产出过的指标才会成列，这样 TPC-H 的 `reference` 运行和 `official` 运行就不会拼出一张满是破折号的表。表后是最多四个指标的趋势图，历史是有界的（每个平台最近 30 次运行）。
* **记录**自上而下地读：它发现了什么、机器本身有没有正常工作、它测到了什么、这些数能不能信。十一个被记录的生命周期阶段被收拢成**一行健康摘要**——“一切正常 · 11 个阶段全部通过”——点开展开成完整的四相位阶段条；一旦有阶段失败，这一行改为直接点名那个阶段。
* **轨迹**页是一棵可缩放的树。拖动上方的总览条即可收窄时间窗口，每根条都按新窗口重画——是时间轴真的动了，而不是窗口外的 span 只被调暗。选中一行会在右侧浮出它的详情，且不把任何一行挤出视野；并发跑的子节点打包成若干泳道，可以点进去；每一行印两个时长：落在窗口内的那一段，以及这个 span 永不变化的总计。聚合与表格两个标签页在树的下方，统计的是窗口内的 span。每次运行都有轨迹——见下文“执行轨迹”。

`#/runs` 是从前那张所有运行的平铺表，带客户端过滤框。当你已经知道自己在找哪次运行时它确实好用，所以它仍然在——只是不再是大门。

## 写能力

用 `--allow-write` 起来之后，控制台能做两件事。两件事围的篱笆是同一套：共用的 `Host` 守卫、必须携带的 `X-Csbench-Write: 1` 头（自定义头是跨域 `<form>` 够不着的，正好堵死简单表单 CSRF），以及在读取 body 的第一个字节之前、就从 `Content-Length` 上判掉的 64 KiB 上限。

**目标**（`configs/*.yaml`）可以新建、编辑、删除。从网络上来的名字永远是名字、不是路径：它必须匹配 `^[A-Za-z0-9._-]{1,64}$`（并排除 `.` 与 `..`——这两个完全符合该模式，却指的是目录），而且 `resolve()` 之后的路径必须仍在 configs 目录内——这一步必须在 resolve 之后做，因为符号链接能通过任何名字校验。YAML 先解析再落盘，所以一个加载不了的文件永远到不了磁盘。

key 看着像凭据的值（`secret`、`token`、`password`、`key`、`credential`）会以 `***` 返回；一旦发生脱敏，文件原文就整份不发——原文是唯一没法安全脱敏的东西，所以这种目标在这里只读不可编辑。带着 `***` 的 `PUT` 会被拒绝：那样的 body 是一次脱敏读取正在往回写，保存它会把真正的凭据覆盖成三个星号。

**运行**可以发起。请求体是一组固定字段——领域、基准、平台、目标**名字**、标量参数、重复、预热——并对着当下的插件注册表校验。没有「传一段命令行」的字段，也没有「传一个路径」的字段。`repeat` 有上限（20），因为它正是把一次点击变成一下午付费工作量的那个旋钮；并发第 5 个运行会被 429 拒掉，而不是排队。运行是一个分离的子进程（`sys.executable -m clousight_bench run --run-id ...`），所以回包能在运行开始之前就叫出它的名字，浏览器也就能立刻打开它的实时视图。

表单刻意**不**强制的一件事：某个基准是否「属于」某个平台。注册表里根本没有这层对应关系，任何规则都会是这段代码自己编的。它转而展示每个基准实际在哪跑出过记录，并在平台会花钱、或适配器还是骨架时给出警告，把选择权留给读者。

控制台把自己的记事放在 `results/.launches/` 下——它被要求启动了什么，放在结果旁边而不是结果里面。这也正是一个目标能说出「有多少次运行用过我」的原因：封存记录里并不保存它拿到的是哪个配置文件。

## 说人话

查看器通过一层统一的去黑话层 `web/src/lib/glossary.ts` 渲染内部词汇，遵守两条规则。

**永远两层，不是一层。** 人话标签在前，原始 key 以等宽字体保留在下方——`Typical query time` 之下是 `tpc-h.geomean_latency_ms`，`Seal evidence` 之下是 `SEAL`。没有任何东西因此变得不可审计，工程师也依然能一眼对上字段名。任何开关都不会改变这一点。

**绝不编造。** 一个不认识的测量 key 仍然会被渲染：它会拿到一个整理过的标签，格式则从它声明的 unit 推断，或者谨慎地从后缀推断。但它拿不到方向。除非有人写过 spec，否则 `betterIs` 保持 `"none"`——因为把一个上升的数说成“更好”而它其实是回退，比什么都不说更糟——所以未知指标在对比表里永远不会被加粗为领先者。

同一层还解释了两个最容易让人误会的东西：`invalid`（我们根本没测到任何东西——这不是被测系统的失败）与 `failed`（我们测了，然后它坏了）的区别；以及 `PUBLISH: skipped`——它是一条持久的声明，说明没有任何发布器碰过这条已封存的记录，而不是一个错误。

**工程师视图**是页头里一个持久化的开关。关掉时，记录页展示的是结论。打开后，它增加一块面板，完整展示记录的 `fingerprints`、`identity`、`environment` 和 `extensions`——正是这些字段让一次运行可复现、可比较，而旧版查看器压根没有渲染过它们。这个开关**不会**隐藏的东西是：每个标签下面的原始 key，两种状态下都在。

## 磁盘上的进度平面

实时进度是与已封存记录刻意分开的第二个平面（`src/clousight_bench/core/progress.py`）：

```
results/.progress/<run_id>/
├── state.json     当前快照，原子替换（写 tmp + os.replace）
├── stream.jsonl   仅追加、有序的事件日志；每个事件带单调递增的 `seq`
└── cancel         零字节标记——读者唯一可以创建的东西
```

几条关键性质：

* **可丢弃，且在记录之外。** 这里的任何东西都不被 `record_digest` 覆盖，任何东西都无法改变结论，且一旦已封存记录存在，整个目录就会被清掉。任何时刻删掉 `results/.progress/`，除了实时视图什么都不会丢。
* **刻意加点前缀。** 一个带 `status` 字段的 `state.json`，距离被一句随手写的 `rglob("*.json")` 当成结果记录只有一步之遥。
* **尽力而为。** 每次写入都被兜住：IO 失败只在 debug 级别打一条日志，把该次运行剩下的进度平面关掉，测评继续跑。
* **有界。** 超过上限后写入方选择**丢弃**而不是继续增长，而且只丢可丢的类别——`log` 和 `sample`。`stage` 和 `step` 永远不丢：它们就是这次运行的形状。丢弃计数会写进 `state.json` 并在 UI 上明示。
* **会被回收。** 运行启动时和查看器启动时各扫一次陈旧目录，这样一台在测评中途断电的机器不会永远显示一行幽灵般的“running”。一个仍然声称在运行、但进程已经消失的快照会被报成 `abandoned`，而不是永远挂着。
* **有版本。** `state.json` 带 `"schema": "progress/1"`；不认识该 schema 的读者应当忽略该目录，而不是猜。

三个环境变量用来调节它：

| 变量 | 默认值 | 它限制什么 |
| - | - | - |
| `CLOUSIGHT_PROGRESS_MAX_LINES` | `20000` | `stream.jsonl` 的行数上限，超过后开始丢 `log`/`sample` 事件 |
| `CLOUSIGHT_PROGRESS_MAX_BYTES` | `8388608`（8 MiB） | `stream.jsonl` 的字节上限，效果同上 |
| `CLOUSIGHT_PROGRESS_GRACE_S` | `120` | 已结束运行的目录存活多久，以便订阅者读到交接信息 |

## API

| 路由 | 返回 |
| - | - |
| `GET /api/meta` | 结果目录的 basename、版本、记录数、飞行中的运行数 |
| `GET /api/records` | 每条记录的摘要，最新在前 |
| `GET /api/board` | 域 → 套件 看板，含每个套件最新一次运行 |
| `GET /api/suite/<domain>/<suite_id>` | 该套件的各平台：最新运行、有界历史、指标并集 |
| `GET /api/record/<run_id>` | 完整记录字典 |
| `GET /api/record/<run_id>/trajectory` | `{spans, t0, source}`——见下文“执行轨迹” |
| `GET /api/suites` | 已装基准：钉住的数据、计分器、在哪跑过 |
| `GET /api/platforms` | 这个构建带了适配器的云平台 |
| `GET /api/plugins` | 所有已注册的扩展点，以及由谁提供 |
| `GET /api/targets` | `configs/*.yaml` 摘要（读：始终开放） |
| `GET /api/targets/<name>` | 单个目标，疑似凭据的值已脱敏 |
| `GET /api/runs/options` | 新建运行表单可以提供哪些选项 |
| `GET /api/runs/prefill/<run_id>` | 「照此新建」从哪些字段开始 |
| `GET /api/progress` | `{runs: [...]}`——进度平面仍然知道的所有运行 |
| `GET /api/progress/<run_id>` | `{state, events}`；`?since=<seq>` 只返回更新的事件 |
| `GET /api/progress/<run_id>/stream` | Server-Sent Events；`?since=<seq>` 断点续传 |
| `POST /api/progress/<run_id>/cancel` | 请求中止（守卫见下） |
| `POST /api/runs` | 发起一次运行 → `202 {run_id}` —— **仅 `--allow-write`** |
| `PUT /api/targets/<name>` | 写入一个目标 —— **仅 `--allow-write`** |
| `DELETE /api/targets/<name>` | 删除一个目标 —— **仅 `--allow-write`** |

其余一律返回带 JSON body 的 404——这个应用是 hash 路由，没有会把打错的地址变成 HTTP 200 的 SPA 回退。

SSE 流在快照变化时发 `state` 帧，用 `events` 帧携带流事件，每 15 秒发一条 `: heartbeat` 注释以免空闲代理掐断连接，最后发一帧 `done`。它每 400 毫秒轮询这两个文件，而不是监视它们——这个平面刻意就是没有通知通道的普通文件。每条流在其整个生命周期里独占一个服务器线程，因此有硬性上限：**同时 8 条流**（第 9 条拿到 503），以及每条流 **6 小时**的生命上限。每个事件都带 `seq`，浏览器在重连后用它作为续传令牌。

## 开发与构建

查看器是位于 `web/` 下的 React + TypeScript + Vite + Tailwind 应用。其生产构建产物**已提交**在 `src/clousight_bench/resources/viewer/dist/` 并随 wheel 一起发布，因此安装本包永远不需要 Node。

```bash theme={null}
scripts/build_viewer.sh    # npm ci + npm run build -> resources/viewer/dist/
cd web && npm test         # vitest：路由、词表、ETA、格式化、实时 step 嵌套、事件流归约
cd web && npm run dev      # Vite 开发服务器（API 指向一个运行中的 csbench serve）
```

修改 `web/` 下的任何内容后，需在同一变更中重新构建并提交生成的 `dist/`——测试校验的是已提交的构建产物，而不仅是源码。CI 的 `viewer-dist` job 会跑 `npm test`、重新构建，并在已提交的 `dist/` 与重建结果不是逐字节一致时失败。

## 范围与安全

* 默认绑定 `127.0.0.1`。没有任何鉴权——请勿暴露到公网接口。
* `Host` 头不是绑定主机或 localhost 别名的请求一律 403（DNS rebinding 守卫）。该守卫位于方法分发之上，因此 `POST` 不可能落到某个措辞不同的副本后面。
* **写路由默认关闭**，不加 `--allow-write` 时返回 405 并说出该加哪个 flag。读目标**不**受这个开关限制：一个在只读服务器上什么都列不出来的目标页，就成了一个关于 flag 的页面。已封存的记录在两种模式下都没有写路由。
* **中止端点**早于这个开关，被单独围了起来：共用的 Host 守卫、必须携带的 `X-Csbench-Progress: 1` 头（自定义头是跨域 `<form>` 够不着的，这正好堵死简单表单 CSRF 这一形态）、拒绝任何请求体，以及 run\_id 必须先是一个纯 `[A-Za-z0-9._-]+` token 才能去指代磁盘上的任何东西。它能做的极限，就是给一次仍在运行的运行创建一个零字节标记。
* **缓存策略**：文档和所有 JSON API 响应一律 `no-store`——它们全都是活状态（SSE 流发的是 `no-cache`）；`/assets/*` 一律 `public, max-age=31536000, immutable`——这些文件名由 Vite 构建按内容哈希生成。没有前半条，升级 `csbench` 后刷新会拿到缓存的 `index.html`，而它引用的 asset 哈希在新 wheel 里并不存在：一个白屏，控制台里还什么都不说。
* 构建后的应用是**离线优先**的：一切（React、ECharts、样式、两套语言词典）都打包进已提交的 `dist/`，只使用系统字体栈，**无任何外部请求**——构建期守卫加测试共同强制所有 dist 文件不含绝对 URL。
* 它在严格 CSP（`default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'`）下提供服务，无任何内联内容（测试强制 `index.html` 不含内联脚本、样式或事件处理器）；服务器提供的字符串始终以纯文本渲染，绝不作为标记解析（`dangerouslySetInnerHTML` 被测试禁用）。
* 静态文件服务在**任何路径拼接之前**就做包含性检查；`..`、路径分隔符、冒号和未知扩展名一律返回 404。
* artifact 与 trace 的读取都会先 resolve，并且必须留在结果目录内部。`/api/meta` 只公开结果目录的 **basename**，绝不公开完整路径——进度交接里的记录路径用相对路径，也是同一个理由。

## 执行轨迹

一次运行的轨迹是**一棵树**：根是 `csbench.run`，下面是生命周期阶段，再往下——对于会上报的测评——是该测评自己的 span，嵌在产生它们的那个阶段里，一直细到单条查询。

这需要两件事，而且从外面都看不出来。

**阶段需要一条真实的时间线。** `emit_run_trace` 以前把阶段按时长首尾相接摆出来，所以只有"花了多久"是真的，"什么时候发生"是编的。现在 `run.stage_spans` 记录每个计时阶段相对运行开始的真实 `[start_ms, end_ms]`。两个后果：套件自带时间戳的 span 能落到该落的位置；阶段之间的空隙变得可见，而一串时长是显示不出空隙的——第一次带真实标记的运行就暴露了 `PREFLIGHT` 之前有五秒钟，此前没人看得见。这个字段是可选的增量字段，没有它的旧记录仍会通过旧的重建方式渲染。

**两半必须接起来。** 它们本来就是同一条 trace——套件在 `DriverContext` 上拿到的就是这次运行的 `trace_id`——但套件的根 span 没有父节点，又待在自己的文件里，于是查看器只能二选一。现在 `emit_run_trace` 会把套件的轨迹重新发射为 `csbench.stage.EXECUTE` 的子节点。套件自己的 artifact 保持逐字节不变：它是被测系统未经改动的陈述，其 sha256 钉在记录里；所以**运行轨迹**成为合并后的视图，而 artifact 仍是第一手来源。时间戳落在阶段窗口之外的 span 保留其原值——把它裁进去只会藏起最值得看见的那处不一致。

### 读者拿到的是什么

`/api/record/<run_id>/trajectory` 会在 `source` 字段里说明返回的是三者中的哪一个，判断依据是 span 本身而不是版本号：

* **`full`**——`results/traces/<trace_id>.jsonl`，已合并测评自己的 span。完整链路。这是常态。
* **`artifact`**——记录里 `kind=trajectory` 的边车文件，用于合并机制出现之前写下的记录。细节在，阶段不在。
* **`lifecycle`**——不含任何测评 span 的运行轨迹，因为这次测评什么都没上报。每次运行至少有这一层。

一个**声明了却读不出来**的 artifact，在运行轨迹无法取代它时仍然返回 404——也就是合并前的那种记录：记录声称的是那个文件，改渲染六根阶段条会让读者把框架误当成整次运行。

### 哪些测评会上报

一个测评要让自己的 span 出现在 `EXECUTE` 里，就要写一个 `kind=trajectory` 的 artifact。内置的这些在真实路径上都会写：

| 测评 | `EXECUTE` 里的 span |
| - | - |
| TPC-H / TPC-DS，reference 模式 | `<suite>.load`、`<suite>.query-set`，以及每条查询一个 `<suite>.q<nr>` |
| TPC-H / TPC-DS，`mode: official` | 完整的阶段机——load、power、refresh function、每条流的 throughput 查询、data maintenance、ACID |
| YCSB | `ycsb.load`、`ycsb.run`，以及可靠性计划触发时的 `ycsb.disruption.<action>` |
| TPC-C | `tpc-c.benchbase`（单个不透明的 JVM 进程） |
| SWE-bench | `swe_bench.run`、`swe_bench.apply_patch` |

**mock 路径不封存任何 span。** 内置 fixture 里是预置的固定耗时，一张由编造数字画成的瀑布图比没有瀑布图更糟，所以 `mode: mock` 的运行显示为 `lifecycle` 并如实标注。

如何写一个，参见[上报进度](/docs/zh/adding-a-suite#上报进度)。两者受同一条规则约束：span 可以**记录**自一段被测量区域已经取过的标记——上表里的每条查询 span 就是 `latency_ms` 所依据的那个区间本身——但绝不能在"自身墙钟就是测量值"的窗口里**写**任何东西。

MMLU 与 GSM8K 是这条规则前半句唯一有案可查的例外。它们的**实时**条目步骤取自条目循环自己的标记（覆盖整次调用），而**封存**的 `latency_ms` 是 `chat_once` 内部另取的一次读数——从发出 HTTP 请求之前到解析完响应之后。两者在每次运行中都会相差「调用前后的准备开销」，在开启 `params.retry` 时还会再相差失败尝试与退避睡眠——所以实时数字总是两者中较大的那个。这是有意为之：实时样本回答"这道题跑了多久"，封存值回答"端点有多快"。

### span 形态

主要的 span 形态是 **v3 OTel 原生** span（`core/sut_span.py`）：`span_id / trace_id / parent_span_id / name / start_unix_nano / end_unix_nano / status / attributes`，其中 `status` 为 OTel 的 `OK` / `ERROR` / `UNSET`，且 `attributes` 必须至少包含一个语义约定命名空间的键作为 kind 判别键——`gen_ai.*`（模型与工具调用）、`db.*`（查询）或 `csbench.*`（阶段、流，以及运行自身的生命周期）。旧版 **v2** span（`parent_id`、`t_start` / `t_end`、`attrs`、字面 `kind`）仍被查看器接受并归一化。

查看器按 kind 上色，并把运行自身的生命周期当作**框架**而非一个数据系列：它取的是不抢眼的坐标轴色，好让框架不与挂在里面的工作争视线。记录如何引用 artifact 参见结果记录 schema（`src/clousight_bench/resources/schemas/result-record-0.4.schema.json`）。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.