Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Sherlog Context

项目领域术语表。不含实现细节,仅定义领域概念及其边界。

<!--
Maintainers: this is a glossary, not a spec. Terms are added lazily —
only when they are resolved and need to be shared across agents.
See domain-modeling skill for the definition format.
-->

## 架构分层

Sherlog 仓库有两条明确的技术栈边界:

| 层 | 语言 | 角色 | 路径 |
|---|---|---|---|
| **Production CLI** | Rust | 用户安装的 `shlog` binary:SQLite FTS5、tokenizer、sync、find/read/stats | `rust/src/`,产物 `target/release/shlog` |
| **Eval harness** | TypeScript | 开发期裁判:fork 被测 CLI 当子进程,观测延迟/吞吐/正确性/契约。不实现检索逻辑,不是 product runtime | `eval/`,`src/`(legacy TS oracle) |

eval harness 是"裁判",Rust binary 是"选手"。`eval/perf-bench.ts` 做的事情是 `spawn(<candidate>, ["find", "豆包输入法", ...])` 然后掐表——它自身不执行 tokenization、不参与检索。contract-gate、acceptance-gate、dogfood runner、concurrency-bench 都遵循同一模式。Harness 可以用开发期 Node `node:sqlite` 读已生成 index 的 `dbstat` 做体积记账;这不是检索路径,也不进入发布态 CLI。

未传 `--bin` / `--cli-argv-json` 时,harness 默认测 TypeScript oracle,不是 Rust production candidate。

## 性能

- **合成烟雾**(synthetic smoke):对确定性小 fixture 跑 `eval:perf` / `eval:perf:concurrency` 得到的延迟/吞吐数字。用来隔离回归、不碰开发者真实数据。不代表真实 Codex/Pi/Claude 的文件体积或命中基数。
_Avoid_: 性能基线(在尚未有进 git 的对照 JSON 时)、可复现真实负载

- **本机校准**(private calibration):开发者对自己已有 index 做的只读测量(必须同时显式 `--root` 与 `--db`,建议 `--skip-sync`)。回答「我这份库、这台机器上容量如何」。数字只对标定它的那份库和那台机器有意义,不进 git 当回归门。
_Avoid_: 默认负载、CI 基线

- **性能回归门**(perf regression gate):发布流程里对**合成烟雾**数字的一次性检查。当前尚未落地;计划只在 release workflow 跑,不在 PR CI 跑,也不用本机校准数字当门槛。

- **业务性能**(end-to-end CLI performance):用户可感知的 CLI 命令进程级 wall-clock 延迟、内存与 DB 体积。由 `eval/perf-bench.ts`(串行)和 `eval/concurrency-bench.ts`(并发)测量。默认测合成烟雾;本机校准须显式 opt-in。

- **并发性能**(concurrency performance):多个独立 `shlog` 只读进程同时访问同一 SQLite index 时的吞吐与 tail latency。用于容量观察,不进入回归门。

- **组件性能**(component-level micro-benchmark):单个内部模块(如 tokenizer)的吞吐、分配次数、长文本缩放。当前尚不存在。

## 数据与负载

- **合成 fixture**(synthetic fixture):由 `eval/perf-fixture.ts` 确定性生成的临时 Codex JSONL。按**消息条**抽签约 60% CJK / 25% Latin / 15% 路径,体积由 `--fixture-mb` 控制(默认 16MB)。相同参数跨机器可复现。这是合成烟雾的默认负载,不是真实会话的形状模型,也不用于正确性或相关性测试。
_Avoid_: 真实语料、dogfood 数据、形状拟合语料

- **dogfood 数据**(dogfood data):开发者本机的真实 agent session 历史。只用于质量线(dogfood eval、acceptance 的人工对照)和性能线的本机校准。perf harness 默认不触碰它。
3 changes: 1 addition & 2 deletions docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,13 @@ message 与 session-profile 共用 `documents`/`documents_fts`,但 evidence pr
- dogfood runner 已复用统一的 CLI-under-test 解析,可通过 `--cli-argv-json`、`SHLOG_CLI_ARGV_JSON` 或 `SHLOG_BIN_UNDER_TEST` 显式绑定 native candidate,并把实际 argv/source 写入 scorecard;无 override 时才默认 TypeScript oracle;
- executable-neutral contract gate 已把 help prose、query-only coverage freshness、typed error semantics 与 native strict-incomplete reason 编码为 intentional-difference policy;当前 `target/release/shlog` 对 TypeScript reference 实测 **24/24**;
- synthetic acceptance gate 已显式绑定同一 native release candidate;message hit、session-profile hit、CJK、source-aware read、command restatement 等 evidence-level fixtures 当前实测 **8/8**;
- candidate-aware perf harness:可显式选择 release binary、记录 process/operation latency、RSS、artifact/DB size 和 progressive reads;
- candidate-aware perf harness:可显式选择 release binary、记录 process/operation latency、RSS、artifact/DB size 和 progressive reads;默认跑确定性合成烟雾 fixture(隔离、不代表真实体积分布),本机校准必须同时显式 `--root` 和 `--db`(建议 `--skip-sync`);附 concurrency 补充 harness;尚未有进 git 的回归基线 JSON,也尚未按真实体积分布做形状拟合语料;
- Rust unit/integration tests:sources、index、sync、migration、retrieval、app;
- native CI 会实际构建并检查 `target/release/shlog` 后以 `--require-candidate` 运行 contract/acceptance;release workflow 则下载 Linux GNU archive、解包并验证其中的 executable,再运行同一 gates;
- native release workflow:macOS arm64/x64、Linux x64 GNU archives、SBOM、checksums、attestation、installer/formula。

### 仍需收口

- 固化 initial/no-op/append sync 与 find/read/status 的性能基线;
- 完成首次 native tag/release 前的全量 Rust、Node oracle/eval、workflow/installer gates;当前 24/24 contract 与 8/8 acceptance 只证明本地 release candidate,不代表 release 已发布;
- 发布后回读 GitHub assets/attestations,并独立验证 installed `shlog` 的路径、`--version` 与 smoke;当前 global `shlog` 仍是旧发布版 `0.4.4`。

Expand Down
77 changes: 70 additions & 7 deletions eval/PERF_BENCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,41 @@ npm run eval:perf -- --root <root> --db <db> --skip-sync --json-only

## Safety 与输入范围

无参数的兼容默认值是本机默认 Codex root 与默认 state DB,并在读测试前执行 strict `sync`。这会修改所选 SQLite,适合明确的本机 dogfood,不适合作为隔离基准。
有且仅有两种负载,不要混:

推荐显式传入 sanitized fixture root 和独立 DB。若 DB 已预建,使用 `--skip-sync`;此时 DB 必须存在,harness 不执行任何 sync:
| 模式 | 怎么进 | 测的是什么 | 会不会碰真实数据 |
|---|---|---|---|
| **合成烟雾**(默认) | 不传 `--root`、不传 `--db` | 隔离、可复现的短句 fixture;用来看有没有崩或慢一个数量级 | 不会 |
| **本机校准** | **同时**传 `--root` 和 `--db` | 你自己的已有 index;回答「我这份库有多快」 | 只读(请加 `--skip-sync`) |

只传其中一个路径会直接失败:以前会用另一个开发者本机默认(`~/.codex/sessions` 或状态目录里的 index)补齐,那会扫到或写到真实数据。`--fixture-mb` 不能和 `--root`/`--db` 混用。

git 里只承认合成烟雾数字。本机校准数字不要当回归门,也不要假装代表其他机器或其他 source。

### 本机校准(opt-in,只读)

```bash
npm run eval:perf -- \
--bin ./target/release/shlog \
--artifact ./target/release/shlog \
--root /absolute/path/to/fixture/sessions \
--db /absolute/path/to/fixture/index.sqlite \
--skip-sync \
--json-only
--root ~/.codex/sessions \
--db ~/.local/state/shlog/index.sqlite \
--skip-sync
```

### 合成烟雾 fixture

`--fixture-mb <n>` 控制正文体积(默认 16)。`eval/perf-fixture.ts` 按消息条抽签约 60% CJK / 25% Latin / 15% 路径;相同参数跨机器可复现。这**不是**真实 Codex/Pi/Claude 的文件体积模型(真实 Codex 往往是少量大文件,合成烟雾是大量短句小文件)。

```bash
# 4 MB 快速 smoke
npm run eval:perf -- --bin ./target/release/shlog --fixture-mb 4 --json-only

# 保留生成文件供检查
npm run eval:perf -- --bin ./target/release/shlog --fixture-mb 4 --keep-fixture
```

烟雾 fixture 的 sync 走 `--best-effort`(临时目录在 macOS 上可能触发 strict 的 `source_file_set_changed`)。本机校准不要 sync;若省略 `--skip-sync`,strict sync 会写你传入的那个 `--db`。

`status` 会按公开 contract 建立 live privacy-filtered inventory 并计算 requested selector coverage;它不返回/检索正文、不写 index,但 cache miss 可流式读取 raw accepted records/body,成本可能为 O(raw bytes),exact `mtime_ns`/checkpoint cache hit 则不重 parse。`find`、`read-range`、`read-page`、`stats` 只读 index。Harness 会把显式 root 传给 status/find,但 find 不自行扫描 raw transcript freshness。

## 被测 command shapes
Expand Down Expand Up @@ -110,6 +131,48 @@ npm run eval:perf -- \

无 executable override 时,dogfood 与其他 eval runner 一样默认使用 TypeScript oracle。

## 并发基准

`npm run eval:perf:concurrency` 测的是**同时多个独立 `shlog` 进程**访问同一个只读 SQLite index 时的吞吐与 tail latency。负载选择与串行 harness 相同:无路径 = 合成烟雾(自动 sync `--best-effort`);同时给 `--root` 和 `--db` = 本机校准(不 sync)。

```bash
# 默认:合成烟雾
npm run eval:perf:concurrency -- --bin ./target/release/shlog

# 本机校准(只读,必须两个路径一起传)
npm run eval:perf:concurrency -- \
--bin ./target/release/shlog \
--root ~/.codex/sessions \
--db ~/.local/state/shlog/index.sqlite \
--shapes "find:hammerspoon|find:edge tts|read-range|read-page|status" \
--levels "1 2 4 8 16 32" \
--total 80
```

- executable selector 与串行 harness 完全一致(`--bin` / `--cli-argv-json` / 环境变量 / TS reference fallback)。
- command shapes:`find:<query>` 构造 `find` 命令;`read-range` / `read-page` / `status` 是字面命令。read shapes 会先用 `list --limit 1` 解析一个真实 session ref 作为 anchor;解析失败时只跳过该 shape 并警告,不使整个 run 失败。
- 方法:worker 池 + 共享任务队列,每个 op 独立 spawn 一个被测进程;并发度 = worker 数。每个 level 的记录包含:
- `throughputPerSec`(完成 ops / wall time)
- per-op `processE2E` 的 p50/p95/p99/max(毫秒)
- payload `elapsedMs` 的 p50/p95/p99/max(若被测命令提供;`status` 不提供时为 `null`)
- `errors`(非零退出计数)
- 默认写入 `data/shlog-perf/concurrency/<timestamp>/report.json` 与 `report.md`;`--json-only` 只向 stdout 输出。

### 本机校准观察(2026-08-17,Apple M4 / 10 核 / 16GB)

这是作者机器上对**真实** Codex index 的只读校准,**不是**合成烟雾,也不是 git 回归基线。被测 `target/release/shlog` 0.5.1(native),6217 sessions / 318k messages / 420MB SQLite,热缓存。数字来自 `npm run eval:perf:concurrency`(`total=40`、`levels 1 2 4 8 16 32`),为 per-op E2E p50/p95(毫秒)与峰值吞吐(ops/s):

| shape | 1 并发 p50/p95 | 4 并发 p50/p95 | 16 并发 p50/p95 | 峰值吞吐(@并发) |
|---|---|---|---|---|
| find:hammerspoon | 12.4 / 13.3 | 18.7 / 24.3 | 82.2 / 114.0 | 204.7 @4 |
| find:edge tts | 33.9 / 41.4 | 49.8 / 64.8 | 176.1 / 250.3 | 91.4 @16 |
| find:豆包输入法 | 13.1 / 13.9 | 17.8 / 19.2 | 74.7 / 105.8 | 222.2 @4 |
| read-range | 4.3 / 5.3 | 4.7 / 6.4 | 9.5 / 16.9 | 1257.6 @16 |
| read-page | 4.2 / 4.8 | 4.7 / 6.0 | 11.0 / 22.7 | 1226.9 @32 |
| status | 84.1 / 123.7 | 106.1 / 121.5 | 359.9 / 558.4 | 45.9 @8 |

> 该基线是热缓存稳态;冷启动首击明显更慢(`find` 首次可达 ~0.6s、`status` 首次 ~2.5s)。并发读路径没有写锁,实测 0 error;超过 ~8 并发时 find 类 latency 劣化明显,超过 ~16 后吞吐不再增长,建议作为限流参考而不是无限开并发。

## 输出

默认写入:
Expand Down
205 changes: 205 additions & 0 deletions eval/concurrency-bench-core.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { describe, expect, test } from "vitest";
import {
DEFAULT_LEVELS,
DEFAULT_SHAPES,
DEFAULT_TOTAL_PER_LEVEL,
aggregateLevelStats,
buildConcurrencyReportMarkdown,
parseConcurrencyArgs,
parseLevels,
parsePositiveInt,
parseShapes,
shapeCommand,
type ConcurrencyReport,
type OpSample,
} from "./concurrency-bench-core";

describe("concurrency shape parsing", () => {
test("bare tokens become find shapes", () => {
expect(parseShapes("envchain|edge tts")).toEqual(["find:envchain", "find:edge tts"]);
});

test("keeps explicit shapes and mixes with find", () => {
expect(parseShapes("read-range|find:部署 health check|status")).toEqual([
"read-range",
"find:部署 health check",
"status",
]);
});

test("empty input falls back to defaults", () => {
expect(parseShapes("")).toEqual(DEFAULT_SHAPES);
});

test("level list is deduped and sorted; empty falls back", () => {
expect(parseLevels("8 1 4 8")).toEqual([1, 4, 8]);
expect(parseLevels("")).toEqual(DEFAULT_LEVELS);
});

test("positive int parser falls back on garbage", () => {
expect(parsePositiveInt("42", 7)).toBe(42);
expect(parsePositiveInt("0", 7)).toBe(7);
expect(parsePositiveInt("abc", 7)).toBe(7);
expect(parsePositiveInt(undefined, 7)).toBe(7);
});
});

describe("concurrency arg parsing", () => {
test("no paths selects synthetic smoke and does not default to ~/.codex", () => {
const args = parseConcurrencyArgs([]);
expect(args.workload).toBe("synthetic_smoke");
expect(args.fixtureMb).toBe(16);
expect(args.root).toBe("");
expect(args.db).toBe("");
});

test("only --root or only --db is rejected", () => {
expect(() => parseConcurrencyArgs(["--root", "/tmp/root"])).toThrow(/both --root and --db/);
expect(() => parseConcurrencyArgs(["--db", "/tmp/index.sqlite"])).toThrow(/both --root and --db/);
});

test("mixing --fixture-mb with real paths is rejected", () => {
expect(() => parseConcurrencyArgs([
"--root", "/tmp/root",
"--db", "/tmp/index.sqlite",
"--fixture-mb", "4",
])).toThrow(/do not mix --fixture-mb/);
});

test("parses calibration overrides and defaults", () => {
const args = parseConcurrencyArgs([
"--db", "/tmp/index.sqlite",
"--root", "/tmp/sessions",
"--source", "claude-code",
"--shapes", "envchain|status",
"--levels", "1 4 16",
"--total", "40",
"--json-only",
]);
expect(args.workload).toBe("private_calibration");
expect(args.db).toBe("/tmp/index.sqlite");
expect(args.root).toBe("/tmp/sessions");
expect(args.source).toBe("claude-code");
expect(args.shapes).toEqual(["find:envchain", "status"]);
expect(args.levels).toEqual([1, 4, 16]);
expect(args.totalPerLevel).toBe(40);
expect(args.jsonOnly).toBe(true);
// No executable override: resolves to the TypeScript reference by default.
expect(args.commandUnderTest.source).toBe("typescript-reference");
});

test("accepts explicit executable override on smoke", () => {
const args = parseConcurrencyArgs(["--bin", "/tmp/shlog"]);
expect(args.workload).toBe("synthetic_smoke");
expect(args.commandUnderTest.source).not.toBe("typescript-reference");
});
});

describe("shape command construction", () => {
const ctx = { source: "codex", root: "/tmp/sessions", db: "/tmp/index.sqlite", sessionRef: "session-1" };

test("find shape carries query and limit", () => {
const cmd = shapeCommand("find:edge tts", ctx);
expect(cmd).toEqual([
"find", "edge tts", "--source", "codex", "--root", "/tmp/sessions",
"--db", "/tmp/index.sqlite", "--limit", "10", "--json",
]);
});

test("status shape carries the all(root) selector", () => {
const cmd = shapeCommand("status", ctx);
expect(cmd?.[0]).toBe("status");
expect(cmd).toContain("--selector");
expect(JSON.parse(cmd![cmd!.indexOf("--selector") + 1]!)).toEqual({
source: "codex", kind: "all", root: "/tmp/sessions",
});
});

test("read shapes require a resolvable session ref", () => {
expect(shapeCommand("read-range", ctx)).not.toBeNull();
expect(shapeCommand("read-page", ctx)).not.toBeNull();
expect(shapeCommand("read-range", { ...ctx, sessionRef: null })).toBeNull();
expect(shapeCommand("read-page", { ...ctx, sessionRef: null })).toBeNull();
});

test("unknown shape throws", () => {
expect(() => shapeCommand("list", ctx)).toThrow(/unknown shape/);
});
});

describe("level aggregation", () => {
test("computes percentiles, throughput and error count", () => {
const samples: OpSample[] = [
sample(10, 8, true), sample(20, 15, true), sample(30, 22, true),
sample(40, 30, true), sample(50, 38, true), sample(999, null, false),
];
const stats = aggregateLevelStats(4, 6, 300, samples);
expect(stats.level).toBe(4);
expect(stats.total).toBe(6);
expect(stats.errors).toBe(1);
expect(stats.throughputPerSec).toBe(20); // 6 ops / 0.3s
// E2E p50/p95/p99/max over [10,20,30,40,50,999] (R-7 linear interpolation)
expect(stats.p50E2E).toBe(35);
expect(stats.p95E2E).toBeCloseTo(761.75, 1);
expect(stats.p99E2E).toBeCloseTo(951.55, 1);
expect(stats.maxE2E).toBe(999);
// op samples exclude the failed op (no elapsedMs): [8,15,22,30,38]
expect(stats.opSampleCount).toBe(5);
expect(stats.p50Op).toBe(22);
expect(stats.p95Op).toBeCloseTo(36.4, 1);
});

test("handles empty sample set without NaN", () => {
const stats = aggregateLevelStats(1, 0, 1, []);
expect(stats.p50E2E).toBe(0);
expect(stats.p95E2E).toBe(0);
expect(stats.maxE2E).toBe(0);
expect(stats.opSampleCount).toBe(0);
expect(stats.throughputPerSec).toBe(0);
});
});

describe("markdown report", () => {
test("renders shape tables without NaN", () => {
const report: ConcurrencyReport = {
generatedAt: "2026-08-17T00:00:00.000Z",
commandUnderTest: {
executable: "shlog",
prefixArgv: [],
source: "argv-json",
resolvedExecutablePath: "/tmp/shlog",
executableSizeBytes: 100,
artifactPath: null,
artifactSizeBytes: null,
},
sourceId: "codex",
dbPath: "/tmp/index.sqlite",
rootDir: "/tmp/sessions",
sessionCount: 10,
messageCount: 100,
totalPerLevel: 2,
shapes: [
{
shape: "find:envchain",
command: ["find", "envchain", "--json"],
levels: [
{
level: 1, total: 2, wallMs: 20, throughputPerSec: 100, errors: 0,
p50E2E: 10, p95E2E: 15, p99E2E: 18, maxE2E: 20,
p50Op: 8, p95Op: 12, p99Op: 14, maxOp: 16, opSampleCount: 2,
},
],
},
],
};
const md = buildConcurrencyReportMarkdown(report);
expect(md).toContain("## find:envchain");
expect(md).toContain("| 1 |");
expect(md).not.toContain("NaN");
expect(md).toContain("shlog 并发性能基准报告");
});
});

function sample(e2eMs: number, opMs: number | null, ok: boolean): OpSample {
return { ok, exitCode: ok ? 0 : 1, e2eMs, opMs, stdoutLen: 0, stderr: "" };
}
Loading