@xiaohuo123212/elench 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +53 -0
  3. package/README.md +298 -2
  4. package/bin/elench.js +69 -0
  5. package/package.json +37 -4
  6. package/scripts/clean-pycache.js +37 -0
  7. package/scripts/npm_smoke.js +88 -0
  8. package/src/README-/345/205/274/345/256/271/345/261/202.md +26 -0
  9. package/src/ast_util.py +111 -0
  10. package/src/bench.py +376 -0
  11. package/src/borrow.py +282 -0
  12. package/src/bound.py +13 -0
  13. package/src/cfg.py +219 -0
  14. package/src/chart.py +209 -0
  15. package/src/check_docs.py +156 -0
  16. package/src/checker.py +2473 -0
  17. package/src/contract_metric.py +87 -0
  18. package/src/cost_check.py +13 -0
  19. package/src/cost_diff.py +533 -0
  20. package/src/cost_domain/__init__.py +32 -0
  21. package/src/cost_domain/bound.py +739 -0
  22. package/src/cost_domain/cost_check.py +394 -0
  23. package/src/cost_domain/cost_interp.py +1998 -0
  24. package/src/cost_domain/symbolic.py +554 -0
  25. package/src/cost_explain.py +503 -0
  26. package/src/cost_interp.py +13 -0
  27. package/src/cxx_map.py +184 -0
  28. package/src/dispatch.py +314 -0
  29. package/src/dsh.py +3980 -0
  30. package/src/dup_scan.py +180 -0
  31. package/src/elench.py +15 -0
  32. package/src/emit.py +56 -0
  33. package/src/ffi_c.py +70 -0
  34. package/src/fmt.py +115 -0
  35. package/src/formatter.py +596 -0
  36. package/src/grammar.py +272 -0
  37. package/src/guarantees.py +116 -0
  38. package/src/highlight.py +156 -0
  39. package/src/interp.py +2119 -0
  40. package/src/isolated.py +161 -0
  41. package/src/jsonval.py +236 -0
  42. package/src/kappa_vs_measured.py +347 -0
  43. package/src/kinds.py +122 -0
  44. package/src/lexer.py +559 -0
  45. package/src/migrate_docs.py +115 -0
  46. package/src/otel.py +579 -0
  47. package/src/otel_dashboard.py +218 -0
  48. package/src/otel_export.py +380 -0
  49. package/src/otel_local_sink.py +141 -0
  50. package/src/otel_view.py +156 -0
  51. package/src/parser.py +1909 -0
  52. package/src/parser.py.bak +1781 -0
  53. package/src/policy.py +271 -0
  54. package/src/rename_ch.py +81 -0
  55. package/src/requires.py +242 -0
  56. package/src/segment_plan.py +150 -0
  57. package/src/shape.py +130 -0
  58. package/src/stdlib.py +1856 -0
  59. package/src/stdlib.py.bak +1751 -0
  60. package/src/symbolic.py +13 -0
  61. package/src/sync_docs.py +225 -0
  62. package/src/test_checker.py.bak +443 -0
  63. package/src/test_formatter.py.bak +201 -0
  64. package/src/test_invalid.py.bak +342 -0
  65. package/src/test_parser.py.bak +423 -0
  66. package/src/typecheck.py +865 -0
  67. package/src/typecheck.py.bak +741 -0
  68. package/src/typenames.py +56 -0
  69. package/src/walk.py +197 -0
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+ // 打包前清掉 Python 缓存与编译产物。
3
+ //
4
+ // 为什么要这一步:package.json 里用 `files` 白名单时 npm **会忽略 .npmignore**,
5
+ // 于是 src/__pycache__ 里的 .pyc 会被整包带走(实测 67 个文件、~1.3MB)。
6
+ "use strict";
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+
10
+ const root = path.join(__dirname, "..");
11
+ let removed = 0;
12
+
13
+ function walk(dir) {
14
+ let ents;
15
+ try {
16
+ ents = fs.readdirSync(dir, { withFileTypes: true });
17
+ } catch (_e) {
18
+ return;
19
+ }
20
+ for (const e of ents) {
21
+ const p = path.join(dir, e.name);
22
+ if (e.isDirectory()) {
23
+ if (e.name === "__pycache__") {
24
+ fs.rmSync(p, { recursive: true, force: true });
25
+ removed++;
26
+ } else if (e.name !== "node_modules" && e.name !== ".git") {
27
+ walk(p);
28
+ }
29
+ } else if (/\.py[co]$/.test(e.name)) {
30
+ fs.rmSync(p, { force: true });
31
+ removed++;
32
+ }
33
+ }
34
+ }
35
+
36
+ walk(path.join(root, "src"));
37
+ console.log(`prepack: 清理了 ${removed} 个缓存/产物`);
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // npm 包的功能冒烟:把 tarball 真装进临时目录,逐个跑子命令。
3
+ //
4
+ // 为什么要在"装进去"之后再跑:仓库里能跑不等于**装出来的包**能跑
5
+ // (相对路径、PYTHONPATH、缺失的 src 文件,只有在装完之后才暴露)。
6
+ "use strict";
7
+ const { execFileSync, spawnSync } = require("child_process");
8
+ const fs = require("fs");
9
+ const os = require("os");
10
+ const path = require("path");
11
+
12
+ const repo = path.join(__dirname, "..");
13
+ const tgz = process.argv[2];
14
+ if (!tgz) {
15
+ console.error("用法: node scripts/npm_smoke.js <tarball>");
16
+ process.exit(2);
17
+ }
18
+
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "elench-smoke-"));
20
+ fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name: "smoke", version: "1.0.0" }));
21
+
22
+ console.log(` 临时目录: ${dir}`);
23
+ // Windows 上 npm 是 npm.cmd,spawnSync 直呼 "npm" 会 ENOENT。
24
+ const NPM = process.platform === "win32" ? "npm.cmd" : "npm";
25
+ // Node 20+ 在 Windows 上**不允许**直接 spawn .cmd(CVE-2024-27980 的修法),
26
+ // 必须过 shell;tarball 路径加引号防空格。
27
+ execFileSync(NPM, ["install", "-s", `"${path.resolve(repo, tgz)}"`],
28
+ { cwd: dir, stdio: "inherit", shell: true });
29
+
30
+ // 样例:从仓库复制到临时目录(包本身不带样例,用户自带文件)
31
+ const samples = path.join(repo, "文档", "样例");
32
+ for (const f of fs.readdirSync(samples)) {
33
+ // **连成本表与分档表一起复制**:`bench`/`cost` 的判定依赖它们,
34
+ // 只拷 .tu/.eln 会让一部分子命令因为"没有表"而拒绝——那不是包的缺陷。
35
+ if (/\.(tu|eln|yaml)$/.test(f)) fs.copyFileSync(path.join(samples, f), path.join(dir, f));
36
+ }
37
+
38
+ // 直接跑包内的入口脚本:**这才是包的内容**;npm 生成的 .cmd/.sh
39
+ // 只是转发壳,Windows 下还得过 cmd.exe,会干扰参数里的中文路径。
40
+ const bin = path.join(dir, "node_modules", "elench", "bin", "elench.js");
41
+ const shim = path.join(dir, "node_modules", ".bin", process.platform === "win32" ? "elench.cmd" : "elench");
42
+ console.log(` 入口: ${bin}\n 转发壳存在: ${fs.existsSync(shim)}`);
43
+ const run = (args) => {
44
+ const r = spawnSync(process.execPath, [bin].concat(args), { cwd: dir, encoding: "utf8", timeout: 180000 });
45
+ const out = ((r.stdout || "") + (r.stderr || "")).split("\n").filter((l) => l.trim());
46
+ return { code: r.status, first: (out[0] || "").trim().slice(0, 70) };
47
+ };
48
+
49
+ const CASES = [
50
+ ["--help", ["--help"]],
51
+ ["run", ["run", "sum", "--fn=average", "--vec=1,2,3"]],
52
+ ["check", ["check", "sum"]],
53
+ ["parse", ["parse", "sum.tu"]],
54
+ ["fmt --check", ["fmt", "sum.eln", "--check"]],
55
+ ["highlight", ["highlight", "sum.tu"]],
56
+ ["show", ["show", "sum.tu", "sum_kernel"]],
57
+ ["grammar", ["grammar", "sum.tu", "--format=ebnf"]],
58
+ ["cost", ["cost", "sum"]],
59
+ ["cost --explain", ["cost", "sum", "--explain"]],
60
+ ["stats --ops", ["stats", "sum", "average", "--ops"]],
61
+ ["bench", ["bench", "max_pair_sum", "max_pair_sum"]],
62
+ ["export csv", ["export", "sum", "--format=csv", "--out=o.csv"]],
63
+ ["export --full", ["export", "sum", "--full", "--out=o.svg"]],
64
+ ["dispatch", ["dispatch", "sum"]],
65
+ ["measure", ["measure", "sum", "--max-size=256"]],
66
+ ["build", ["build", "sum"]],
67
+ ["cpp(已知限制)", ["cpp", "calculator"]],
68
+ ["header", ["header", "calculator"]],
69
+ ["spec", ["spec", "calculator"]],
70
+ ["effects", ["effects", "calculator"]],
71
+ ["otel view", ["otel", "view", "nope.ndjson"]],
72
+ ];
73
+
74
+ let bad = 0;
75
+ console.log(`\n ${"命令".padEnd(16)} 退出码 首行`);
76
+ for (const [name, args] of CASES) {
77
+ const r = run(args);
78
+ // `otel view` 缺文件就该非 0 —— 那是"用例本身该失败",单独标注
79
+ // 两类"该失败":
80
+ // otel view —— 文件不存在,本来就该报错
81
+ // cpp —— `try` 表达式还不支持(L-16 已挂起),**是已知限制不是包装问题**
82
+ const expectFail = name === "otel view" || name.startsWith("cpp");
83
+ const ok = expectFail ? true : r.code === 0;
84
+ if (!ok) bad++;
85
+ console.log(` ${name.padEnd(16)} ${String(r.code).padEnd(6)} ${ok ? "" : "✗ "}${r.first}`);
86
+ }
87
+ console.log(`\n ${CASES.length - bad}/${CASES.length} 通过(otel view 以"该失败"计入)`);
88
+ process.exit(bad ? 1 : 0);
@@ -0,0 +1,26 @@
1
+ # `src/` 顶层的四个"转发 shim"是**有意保留**的
2
+
3
+ `bound.py` / `cost_check.py` / `cost_interp.py` / `symbolic.py` 各约 350 B,
4
+ 内容是:
5
+
6
+ ```python
7
+ import sys as _sys
8
+ from cost_domain import cost_interp as _real
9
+ _sys.modules[__name__] = _real
10
+ ```
11
+
12
+ 真正的实现在 `src/cost_domain/`,`cost_domain/` 内部用**相对导入**(`.bound` / `.symbolic`),
13
+ 是自洽的。这四个文件只做一件事:让**外部调用方**继续 `from cost_interp import CostInterp`。
14
+
15
+ ## 为什么不用 `from ... import *`
16
+
17
+ 因为它会把名字复制一份到 shim 模块里。而 `tests/cases/test_ops.py` 需要
18
+ **monkeypatch `cost_interp.OP_COST`** —— 只有把 `sys.modules[__name__]` 换成真模块,
19
+ patch 才落在真正被读的那个全局上。复制名字会让"测了个寂寞"。
20
+
21
+ ## 什么时候可以删
22
+
23
+ 当所有调用方(`src/`、`tools/`、`tests/`、`editors/vscode/python/`)都改成
24
+ `from cost_domain.cost_interp import ...`,且 monkeypatch 也改成指向
25
+ `cost_domain.cost_interp` 时。**代价约 200 处 import**,收益 1.4 KB —— 2026-11-07
26
+ 审计的结论是:**不值得**,保留并在此说明。
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2026 Elench contributors
4
+
5
+ """ast_util.py —— 共享 AST 工具(主批 A–D Step 3)
6
+
7
+ **为什么有这个文件**:`_descendants` 原先在 **四个地方**各写了一遍
8
+ (`bound.py`、`cost_interp.py`、`interp.py`、`isolated.py`),
9
+ 其中三份完全一样、一份(`bound.py`)少一个 `None` 保护。
10
+ 重复实现的问题不是"难看",是**改一处忘一处**——这正是 A–D Step 2 抓到的漂移类型。
11
+
12
+ **这个文件只放"纯 AST 工具"**:不涉及任何值域语义,两个值域都能用。
13
+ """
14
+ from typing import Any, List, Optional
15
+
16
+
17
+ def descendants(n: Optional[Any], kind: str, acc: Optional[List] = None) -> List:
18
+ """递归收集某类节点(含 `n` 自身)。
19
+
20
+ `None` 安全:传 `None` 返回空列表(四个旧实现里有三个是这样,
21
+ `bound.py` 那份没有保护——统一到安全版本,因为**更严格的那份是错的**)。
22
+ """
23
+ if acc is None:
24
+ acc = []
25
+ if n is None:
26
+ return acc
27
+ if getattr(n, "kind", None) == kind:
28
+ acc.append(n)
29
+ for c in getattr(n, "children", ()) or ():
30
+ descendants(c, kind, acc)
31
+ return acc
32
+
33
+
34
+ def const_int_value(c: Optional[Any]) -> Optional[int]:
35
+ """从 `const NAME: T = <整数>;` 里取出整数;取不到返回 `None`。"""
36
+ if c is None:
37
+ return None
38
+ for ch in getattr(c, "children", ()) or ():
39
+ if getattr(ch, "kind", None) == "lit-int":
40
+ try:
41
+ return int(ch.value)
42
+ except (TypeError, ValueError):
43
+ return None
44
+ return None
45
+
46
+
47
+ def arms_of(st) -> list:
48
+ """`match` 的所有臂(`arm` 节点)。"""
49
+ return [c for c in (getattr(st, "children", []) or []) if c.kind == "arm"]
50
+
51
+
52
+ def match_scrutinee(st):
53
+ """`match` 的被匹配表达式(第一个非 arm 子节点)。"""
54
+ for c in (getattr(st, "children", []) or []):
55
+ if c.kind != "arm":
56
+ return c
57
+ return None
58
+
59
+
60
+ def arm_pattern(arm):
61
+ """臂的模式节点(`pattern` / `pattern-wildcard` / `pattern-literal`)。"""
62
+ for c in (getattr(arm, "children", []) or []):
63
+ if c.kind in ("pattern", "pattern-wildcard", "pattern-literal"):
64
+ return c
65
+ return None
66
+
67
+
68
+ def arm_block(arm):
69
+ """臂的 block(`Pat => { ... }`);单语句臂返回 None。"""
70
+ for c in (getattr(arm, "children", []) or []):
71
+ if c.kind == "block":
72
+ return c
73
+ return None
74
+
75
+
76
+ def arm_single_stmts(arm) -> list:
77
+ """臂的单语句部分(`0 => return 1,` 里的 `return 1`)。"""
78
+ return [c for c in (getattr(arm, "children", []) or [])
79
+ if c.kind not in ("pattern", "pattern-wildcard", "pattern-literal", "block")]
80
+
81
+
82
+ def stmt_body(st):
83
+ """语句的块体(`if`/`while`/`for`/`block` 都取第一个 `block`)。"""
84
+ for c in (getattr(st, "children", []) or []):
85
+ if c.kind == "block":
86
+ return c
87
+ return None
88
+
89
+
90
+ def stmt_else(st):
91
+ """`if` 的 else 子节点;没有则 None。"""
92
+ for c in (getattr(st, "children", []) or []):
93
+ if c.kind == "else":
94
+ return c
95
+ return None
96
+
97
+
98
+ def while_cond(st):
99
+ """`while` 的条件(第一个非 block 子节点)。"""
100
+ for c in (getattr(st, "children", []) or []):
101
+ if c.kind != "block":
102
+ return c
103
+ return None
104
+
105
+
106
+ def for_iterable(st):
107
+ """`for` 的被迭代表达式(跳过 block 与 param)。"""
108
+ for c in (getattr(st, "children", []) or []):
109
+ if c.kind not in ("block", "param"):
110
+ return c
111
+ return None
package/src/bench.py ADDED
@@ -0,0 +1,376 @@
1
+ #!/usr/bin/env python3
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ # Copyright 2026 Elench contributors
4
+
5
+ # -*- coding: utf-8 -*-
6
+ """bench.py —— 真实测量(动态分析的第 ③ 步)。
7
+
8
+ **测什么**:按 `benchmodel` 造出数据,把函数跑 N 次,取**中位数**纳秒。
9
+
10
+ ## 两把尺子(实参构造共用一份)
11
+
12
+ | 函数 | 量 | 单位 | 和 κ 的关系 |
13
+ |---|---|---|---|
14
+ | `measure` | 墙钟时间 | 纳秒 | **不同单位**:只能归一化后比斜率(`--timing`) |
15
+ | `measure_ops` | `interp` 的操作数计数器 | 操作 | **同单位**:可直接判 `实测 ≤ κ`(默认) |
16
+
17
+ 两者的实参都来自 `_bench_plan` + `_build_args`——**夹具只有一套**,
18
+ 否则两个口径量的不是同一个输入。
19
+
20
+ ## 为什么用中位数
21
+
22
+ 一次异常调度会把平均值拉偏,中位数不会。**我们要的是"通常多快",不是"平均多快"。**
23
+
24
+ ## 替换法(§7.8.2)
25
+
26
+ 候选默认 `inline` 在宿主里,不能单独跑。所以测法是:
27
+ **同一份宿主、同一份数据,只换被测的那个候选**(`force={标签: 候选}`)。
28
+
29
+ ## 内置生成器
30
+
31
+ `benchmodel.data` 里的生成器在真实项目里应由标准库提供;M1 阶段先内置最小集:
32
+
33
+ | 名字 | 生成什么 |
34
+ |---|---|
35
+ | `sorted_u64s(n)` | 升序的 n 个 u64 |
36
+ | `random_u64s(n)` | 伪随机的 n 个 u64(由 n 定种子,可复现) |
37
+ | `make_u8s(n)` | n 个 0..255 |
38
+ | `make_texts(n)` | n 个数字字符的字节数组 |
39
+
40
+ **确定性**:同一个 n 永远生成同一份数据(MO-3 / MB-3 的要求)。
41
+ """
42
+
43
+ import random
44
+ import time
45
+ from typing import Any, Callable, Dict, List, Optional
46
+
47
+ from interp import Interp
48
+ from parser import Node
49
+
50
+
51
+ # ---------------------------------------------------------------- 内置生成器
52
+
53
+ def _rng(seed: int, n: int) -> random.Random:
54
+ """由 (seed, n) 决定随机源。
55
+
56
+ **`seed` 来自 benchmodel 的 `seed = <整数>;`(可省,默认 0)。**
57
+ 以前这里硬编码 `0x5EED + n`,导致 `seed` 字段写了等于没写——**那是装饰性字段**。
58
+ """
59
+ return random.Random((seed & 0xFFFFFFFF) ^ (n * 0x9E3779B1))
60
+
61
+
62
+ def sorted_u64s(seed: int, n: int) -> List[int]:
63
+ r = _rng(seed, n)
64
+ return sorted(r.randrange(0, 1 << 32) for _ in range(n))
65
+
66
+
67
+ def random_u64s(seed: int, n: int) -> List[int]:
68
+ r = _rng(seed, n)
69
+ return [r.randrange(0, 1 << 32) for _ in range(n)]
70
+
71
+
72
+ def make_u8s(seed: int, n: int) -> List[int]:
73
+ r = _rng(seed, n)
74
+ return [r.randrange(0, 256) for _ in range(n)]
75
+
76
+
77
+ def make_texts(seed: int, n: int) -> List[int]:
78
+ r = _rng(seed, n)
79
+ return [ord("0") + r.randrange(0, 10) for _ in range(n)]
80
+
81
+
82
+ #: 内置生成器:名字 → (seed, n) -> 值。
83
+ #: **MB-7 下每个都必须在 `.tu` 里有对应 `spec`**(见 checker 的 E-Bench-BadGenerator)。
84
+ BUILTIN = {
85
+ "sorted_u64s": sorted_u64s,
86
+ "random_u64s": random_u64s,
87
+ "make_u8s": make_u8s,
88
+ "make_texts": make_texts,
89
+ }
90
+
91
+
92
+ # ---------------------------------------------------------------- benchmodel 解析
93
+
94
+ def _desc(n: Optional[Node], kind: str, acc=None) -> List[Node]:
95
+ if acc is None:
96
+ acc = []
97
+ if n is None:
98
+ return acc
99
+ if n.kind == kind:
100
+ acc.append(n)
101
+ for c in n.children:
102
+ _desc(c, kind, acc)
103
+ return acc
104
+
105
+
106
+ def read_benchmodel(decl_mod: Node):
107
+ """返回 (data, scale, seed)。data: {参数: 生成器名};scale: {参数: [点]}。"""
108
+ from checker import _eval_range_points
109
+ data: Dict[str, str] = {}
110
+ scale: Dict[str, List[int]] = {}
111
+ bms = _desc(decl_mod, "benchmodel")
112
+ if not bms:
113
+ return data, scale, 0
114
+ bm = bms[0]
115
+ for b in _desc(bm, "bench-data"):
116
+ for bind in b.children:
117
+ if bind.kind != "binding":
118
+ continue
119
+ gen = bind.children[0] if bind.children else None
120
+ name = None
121
+ if gen is not None and gen.kind == "call" and (gen.name or "") == "structs":
122
+ # **E14**:`pts = structs(Point);` —— 造 `Vec<Point>`。
123
+ # 字段表在这里一次解析完并编码进名字里,`make_args` 就不用再拿 decl_mod。
124
+ _sn = None
125
+ _args = next((c for c in gen.children if c.kind == "args"), None)
126
+ if _args is not None and _args.children:
127
+ _sn = _args.children[0].name
128
+ _fields = []
129
+ for _st in _desc(decl_mod, "struct"):
130
+ if _st.name != _sn:
131
+ continue
132
+ for _f in _st.children:
133
+ if _f.kind != "field":
134
+ continue
135
+ _ty = _f.children[0].name if _f.children else "?"
136
+ _fields.append("%s=%s" % (_f.name, _ty))
137
+ data[bind.name] = "structs:%s:%s" % (_sn or "?", ",".join(_fields))
138
+ continue
139
+ if gen is not None and gen.kind == "call":
140
+ name = gen.name
141
+ elif gen is not None and gen.kind == "path":
142
+ name = gen.name
143
+ elif gen is not None and gen.kind == "lit-int":
144
+ # 字面量:`k = 32;` —— 标量形参最诚实的写法
145
+ name = "#" + str(gen.value)
146
+ data[bind.name] = name or ""
147
+ for b in _desc(bm, "bench-scale"):
148
+ for bind in b.children:
149
+ if bind.kind != "scale-binding":
150
+ continue
151
+ pts, _err = _eval_range_points(bind.children[0] if bind.children else None)
152
+ if pts:
153
+ scale[bind.name] = pts
154
+ seed = 0
155
+ for sd in _desc(bm, "seed"):
156
+ lit = sd.children[0] if sd.children else None
157
+ if lit is not None and lit.kind == "lit-int":
158
+ seed = int(lit.value)
159
+ return data, scale, seed
160
+
161
+
162
+ # ---------------------------------------------------------------- 造数据
163
+
164
+ def make_args(data: Dict[str, str], sized: Dict[str, int], size: int,
165
+ seed: int = 0) -> List[Any]:
166
+ """按 data 生成器造一组实参。`sized` 里有的参数按 size 造,其余造最小规模。
167
+
168
+ **顺序敏感**:`data` 的声明顺序 MUST 等于签名形参顺序(MB-13,由 checker 校验)。
169
+ 生成器可以引用**前面已造好**的参数(MB-5,如 `any_of(xs)`)——
170
+ 所以这里按 `data` 的插入顺序逐个求值,并把已造好的值放进 `bound`。
171
+ """
172
+ out = []
173
+ bound: Dict[str, Any] = {}
174
+ for pname, gen in data.items():
175
+ # **逐参数尺寸**:`sized[pname]` 给了正数就用它,否则跟随 size。
176
+ # 为什么需要:`lcs(a, b)` 的上界是 `a.len × b.len`,而原来只有**第一个**
177
+ # 规模参数跟着 n 变,`b` 永远是 1 —— 于是 O(n²) 的样例被当成 O(n) 测,
178
+ # 实测只有 23 次操作,比值 11614× 毫无意义。
179
+ _explicit = sized.get(pname) if hasattr(sized, "get") else None
180
+ n = _explicit if _explicit else (size if pname in sized else 1)
181
+ if gen and gen.startswith("#"):
182
+ # 字面量标量:原样给(`k = 32;` → 32),不随规模变——这是作者的选择
183
+ try:
184
+ lit = int(gen[1:])
185
+ except ValueError:
186
+ raise RuntimeError(f"benchmodel.data 里的字面量 {gen!r} 不是整数") from None
187
+ # **标量形参也能被 `scale` 驱动**(2026-11-07 修)。
188
+ # `data` 里的字面量是"默认值";一旦这个参数也在 `scale` 里出现
189
+ # (`k in range(1, 64, *2)`),就按当前规模取值。
190
+ # 以前字面量永远赢,于是"参数是数字"的函数**规模列在变、参数不变**,
191
+ # 表是假的(`blur` 实测 7 行 κ/实测 全相同)。
192
+ val = size if pname in sized else lit
193
+ out.append(val)
194
+ bound[pname] = val
195
+ continue
196
+ if gen == "any_of":
197
+ # 从前面已造好的容器里取一个元素;空容器 → T 的类型上界
198
+ src = next(iter(bound.values()), None)
199
+ out.append(any_of(src, seed))
200
+ bound[pname] = out[-1]
201
+ continue
202
+ if gen.startswith("structs:"):
203
+ # **E14**:造 `Vec<T>`,T 是 .tu 里声明的结构体。
204
+ # **只填整数字段**:别的类型(Vec/字符串/结构体)不猜,直接报错。
205
+ from interp import StructVal
206
+ _parts = gen.split(":", 2)
207
+ _sn = _parts[1]
208
+ _fs = [x for x in (_parts[2] if len(_parts) > 2 else "").split(",") if x]
209
+ if not _fs:
210
+ raise RuntimeError(
211
+ "benchmodel.data 里的 structs(%s):找不到这个结构体或它没有字段" % _sn)
212
+ _rnd = random.Random((seed & 0xFFFFFFFF) ^ 0x71F3A0D1)
213
+ _vals = []
214
+ for _f in _fs:
215
+ _fname, _, _fty = _f.partition("=")
216
+ if _fty not in ("u64", "u32", "i64", "i32", "u8", "i8"):
217
+ raise RuntimeError(
218
+ "benchmodel.data 里的 structs(%s):字段 %s 的类型 %r 不是整数,"
219
+ "生成器不会替你猜(E14)" % (_sn, _fname, _fty))
220
+ _cap = 1000 if _fty in ("u64", "i64", "u32", "i32") else 200
221
+ _vals.append((_fname, _rnd.randrange(0, _cap)))
222
+ out.append([StructVal(_sn, dict(_vals)) for _ in range(max(1, n))])
223
+ bound[pname] = out[-1]
224
+ continue
225
+ f = BUILTIN.get(gen)
226
+ if f is None:
227
+ raise RuntimeError(
228
+ f"benchmodel.data 里的生成器 {gen!r} 不在内置集里"
229
+ f"(内置只有 {sorted(BUILTIN)} 与 any_of)")
230
+ val = f(seed, n)
231
+ out.append(val)
232
+ bound[pname] = val
233
+ return out
234
+
235
+
236
+ #: 各整型的类型上界——`any_of` 在空容器上返回它。
237
+ #: **为什么是上界**:固定、可复现、且**必然不在容器里**,
238
+ #: 于是"未命中"这条路径也变成可测的了。
239
+ TYPE_CEILING = {
240
+ "u64": (1 << 64) - 1,
241
+ "i64": (1 << 63) - 1,
242
+ "u32": (1 << 32) - 1,
243
+ "i32": (1 << 31) - 1,
244
+ "u8": 255,
245
+ "i8": 127,
246
+ }
247
+
248
+
249
+ def any_of(container: Any, seed: int = 0, type_name: str = "u64") -> Any:
250
+ """从容器里按 seed 取一个元素;容器为空 → 返回 `T` 的类型上界。
251
+
252
+ **这就是「空数组也要能测」的落地方式**:
253
+ 不是给 `any_of` 加一条特例规则,而是把"取不到"统一处理成一个**越界哨兵**。
254
+ """
255
+ if container is None:
256
+ return TYPE_CEILING.get(type_name, TYPE_CEILING["u64"])
257
+ seq = list(container)
258
+ if not seq:
259
+ return TYPE_CEILING.get(type_name, TYPE_CEILING["u64"])
260
+ r = random.Random((seed & 0xFFFFFFFF) ^ 0x5BF03635)
261
+ return seq[r.randrange(0, len(seq))]
262
+
263
+
264
+ # ---------------------------------------------------------------- 测量
265
+
266
+ def _bench_plan(decl_mod: Optional[Node]):
267
+ """造实参所需的上下文:`(data, sized, seed)`。
268
+
269
+ **`measure` 与 `measure_ops` 必须共用这一份**:一个测纳秒、一个测操作数,
270
+ 量的是同一个函数,输入也必须是同一组(否则两个口径对不上)。
271
+ `sized` = 规模驱动的参数:`scale` 里出现的那些;没有 `scale` 就取第一个参数。
272
+ """
273
+ data, scale_map, seed = (read_benchmodel(decl_mod) if decl_mod is not None
274
+ else ({}, {}, 0))
275
+ if not data:
276
+ raise RuntimeError("没有 benchmodel.data,无法造数据")
277
+ sized = {p: 0 for p in scale_map} or {next(iter(data)): 0}
278
+ return data, sized, seed
279
+
280
+
281
+ def _build_args(data: Dict[str, str], sized: Dict[str, int], size: int,
282
+ seed: int) -> List[Any]:
283
+ """按 benchmodel 造**某一档**的实参。
284
+
285
+ **两个测量口径唯一的实参来源**:`measure`(纳秒)与 `measure_ops`(操作数)
286
+ 都从这里取实参。换实现时**只许改这里**,不许各写一套夹具——
287
+ 夹具一分叉,两个口径比的就不是同一个输入了。
288
+ """
289
+ return make_args(data, sized, size, seed)
290
+
291
+
292
+ def measure(impl_mod: Node, decl_mod: Optional[Node], fn_name: str,
293
+ sizes: List[int], repeats: int = 5,
294
+ force: Optional[Dict[str, str]] = None,
295
+ table_path: Optional[str] = None) -> Dict[int, float]:
296
+ """跑 N 次,返回 {规模: 中位数纳秒}。"""
297
+ data, sized, seed = _bench_plan(decl_mod)
298
+
299
+ out: Dict[int, float] = {}
300
+ for size in sizes:
301
+ args = _build_args(data, sized, size, seed)
302
+ samples = []
303
+ for _ in range(max(3, repeats)):
304
+ it = Interp(impl_mod, decl_mod, table_path=table_path)
305
+ t0 = time.perf_counter_ns()
306
+ it.call(fn_name, args, force=force)
307
+ t1 = time.perf_counter_ns()
308
+ samples.append(float(t1 - t0))
309
+ samples.sort()
310
+ out[size] = samples[len(samples) // 2]
311
+ return out
312
+
313
+
314
+ def measure_ops(impl_mod: Node, decl_mod: Optional[Node], fn_name: str,
315
+ sizes: List[int],
316
+ force: Optional[Dict[str, str]] = None,
317
+ table_path: Optional[str] = None) -> Dict[int, int]:
318
+ """跑一遍,返回 {规模: 操作数}。
319
+
320
+ ## 为什么不再用纳秒
321
+
322
+ κ 的单位是「操作」。拿纳秒去和 κ 比,只能靠**各自归一化**看斜率——
323
+ 那是"两条线长得像不像",不是"上界成立不成立"。
324
+ 操作数和 κ **同单位**,所以可以直接判 `实测 ≤ κ`,**不需要归一化**。
325
+
326
+ ## 口径必须与 `measure` 一致
327
+
328
+ - 实参:同一个 `_bench_plan` + `_build_args`(**不是另写一套夹具**);
329
+ - 同一个 `Interp(impl_mod, decl_mod, table_path=...)`、同一份 `force`;
330
+ - 每个规模点新建一个解释器、`ops_reset()` 后跑一次。
331
+
332
+ **只跑一次**:操作数是确定性的(同一输入、同一分档表 → 同一个数),
333
+ 取中位数没有意义。函数是整棵调用树(含嵌套调用),与 κ(f) 的口径相同。
334
+ """
335
+ from interp import (ops_enable, ops_enabled, ops_of, ops_reset)
336
+
337
+ data, sized, seed = _bench_plan(decl_mod)
338
+
339
+ was_on = ops_enabled()
340
+ out: Dict[int, int] = {}
341
+ try:
342
+ ops_enable(True)
343
+ for size in sizes:
344
+ args = _build_args(data, sized, size, seed)
345
+ it = Interp(impl_mod, decl_mod, table_path=table_path)
346
+ # **每次测量前清零**:计数器是进程级的,不清零就会把上一档累加进来
347
+ ops_reset()
348
+ it.call(fn_name, args, force=force)
349
+ out[size] = ops_of(fn_name)
350
+ finally:
351
+ ops_enable(was_on)
352
+ return out
353
+
354
+
355
+ if __name__ == "__main__":
356
+ import os
357
+ import sys
358
+ from parser import parse_source
359
+
360
+ sys.stdout.reconfigure(encoding="utf-8")
361
+ HERE = os.path.dirname(os.path.abspath(__file__))
362
+ S = os.path.join(HERE, "..", "文档", "样例")
363
+
364
+ for base, fn, label, cands in (
365
+ ("sum", "average", "sum_kernel", ["sum_by_index", "sum_by_iter"]),
366
+ ("search", "binary_search", "search_impl", ["search_linear", "search_binary"]),
367
+ ):
368
+ tu = parse_source(open(os.path.join(S, base + ".tu"), encoding="utf-8").read(),
369
+ base + ".tu").module
370
+ ch = parse_source(open(os.path.join(S, base + ".eln"), encoding="utf-8").read(),
371
+ base + ".eln").module
372
+ print(f"=== {base}.{fn} ===")
373
+ for cand in cands:
374
+ m = measure(ch, tu, fn, [16, 64, 256], force={label: cand})
375
+ print(" " + cand.ljust(16) + " " +
376
+ " ".join(f"n={k}: {v:,.0f} ns" for k, v in m.items()))