"""Build vector figure sources, comparison tables, the formal-data table, and a support ZIP. The generated table rows are f-strings whose value equals the LaTeX line to be written; wrapping them would obscure that one-to-one correspondence, so this module is exempt from the line-length check (the only pylint exception in scripts/). """ # pylint: disable=line-too-long from __future__ import annotations import argparse import csv import hashlib import json import zipfile from decimal import Decimal, ROUND_HALF_UP from pathlib import Path ROOT = Path(__file__).resolve().parents[1] PAPER = ROOT / "docs/paper" PROJECT_RESULTS = ROOT / "project/results" def digest(data: bytes) -> str: return hashlib.sha256(data).hexdigest() def write_tex(path: Path, text: str) -> None: """写 LaTeX 片段;强制 LF 换行。 Windows 下的 CRLF 会让 booktabs 的 ``\\bottomrule`` 在表格内部变成 "Misplaced \\noalign":``\\input`` 读入的首行在 TeX 缓冲中已被 ``\\par`` 化, 表格因此提前结束。所有 ``data/*.tex`` 一律使用 LF。 """ path.write_text(text.replace("\r\n", "\n").replace("\r", "\n"), "utf-8", newline="\n") def figures(rows: list[dict]) -> None: out = PAPER / "figures" out.mkdir(parents=True, exist_ok=True) plan = json.loads((ROOT / "src/mcm26b/guarantee/plans/q4_mincover_v2.json").read_text("utf-8")) lines = [ r"\begin{figure}[htbp]\centering", r"\begin{tikzpicture}[x=0.00145cm,y=0.00145cm,font=\small]", r"\fill[inkblue!5] (0,0) circle (1800);", r"\draw[thick] (0,0) circle (1800);", r"\draw[->,warmgray] (-2900,0)--(2950,0) node[right] {$x$/m};", r"\draw[->,warmgray] (0,-2800)--(0,2850) node[above] {$y$/m};", ] for x in (-2000, -1000, 1000, 2000): lines += [fr"\draw ({x},-35)--({x},35);", fr"\node[below,font=\scriptsize] at ({x},-50) {{{x}}};"] for x, y in plan["points"]: lines.append(fr"\fill[inkblue] ({x},{y}) circle (33);") lines += [ r"\node[anchor=west,align=left] at (3250,900) {圆域:源位置先验\\蓝点:31个检测站};", r"\node[anchor=west,align=left] at (3250,-700) {部分站点在圆域外\\用于接收向外发射的源};", r"\end{tikzpicture}", r"\caption{第四问集合覆盖模型得到的31个检测站及源位置区域。}", r"\label{fig:q4plan}\end{figure}", ] write_tex(out / "q4-plan.tex", "\n".join(lines) + "\n") colors = ["inkblue", "inkblue!50", "warmgray!70", "black!18"] bars = [ r"\begin{figure}[htbp]\centering", r"\begin{tikzpicture}[x=0.00108cm,y=0.85cm,font=\small]", ] for j, row in enumerate(rows): y = len(rows) - 1 - j components = [Decimal(row["derived"]["inferred_movement_time_s"]), Decimal(5 * row["measure_accepted_count"]), Decimal(row["channel_switch_count"]), Decimal(5 * row["cleared_jammer_count"] + 3 * row["clear_failure_count"])] label = f"Q{row['problem_no']}-{row['formal_index']}" bars.append(fr"\node[anchor=east] at (-100,{y + 0.3}) {{{label}}};") current = Decimal(0) for amount, color in zip(components, colors): bars.append(fr"\fill[{color}] ({current},{y}) rectangle ({current + amount},{y + 0.6});") current += amount bars.append(fr"\node[anchor=west,font=\scriptsize] at ({current + 80},{y + 0.3}) {{{current:.1f}}};") bars.append(r"\draw[->] (0,-0.25)--(10500,-0.25);") for tick in range(0, 10001, 2000): bars.append(fr"\draw ({tick},-0.25)--({tick},-0.35) node[below,font=\scriptsize] {{{tick}}};") bars.append(r"\node[anchor=west,font=\scriptsize] at (10400,-0.65) {s};") for x, color, label in zip((0, 2600, 5200, 7600), colors, ("移动", "检测", "换频", "清除")): bars += [fr"\fill[{color}] ({x},-1.3) rectangle ({x + 350},-1.05);", fr"\node[anchor=west] at ({x + 450},-1.175) {{{label}}};"] bars += [ r"\end{tikzpicture}", r"\caption{正式测试的累计虚拟耗时分解。移动时间由总耗时与各动作计数计算。}", r"\label{fig:costbars}\end{figure}", ] write_tex(out / "formal-time-bars.tex", "\n".join(bars) + "\n") def fixed(value: float | None, digits: int = 3) -> str: if value is None: return "--" return str(Decimal(str(value)).quantize(Decimal(1).scaleb(-digits), rounding=ROUND_HALF_UP)) def percent(value: float | None, digits: int = 1) -> str: if value is None: return "--" return fixed(100 * value, digits) + r"\%" def flag(value: bool | None) -> str: return "--" if value is None else ("是" if value else "否") def project_tables() -> None: """Generate the optimal-versus-baseline comparison tables from project/results.""" out = PAPER / "data" out.mkdir(parents=True, exist_ok=True) data = {name: json.loads((PROJECT_RESULTS / f"{name}.json").read_text("utf-8")) for name in ("q1", "q2", "q3", "q4")} examples, random = data["q1"]["aggregate_examples"], data["q1"]["aggregate_random"] two = data["q2"]["aggregate"] three, paired3 = data["q3"]["aggregate"]["variants"], data["q3"]["aggregate"]["paired"] four, paired4 = data["q4"]["aggregate"]["variants"], data["q4"]["aggregate"]["paired"] head = "% Generated by scripts/build_paper_support.py from project/results/*.json." macros = [ ("ProjQOneExampleCases", str(examples["cases"])), ("ProjQOneRandomCases", str(random["cases"])), ("ProjQOneFixedStatusBase", percent(examples["status_accuracy_baseline"])), ("ProjQOneFixedMaeBase", fixed(examples["diameter_mae_baseline_m"])), ("ProjQOneFixedMaeMaxBase", fixed(examples["diameter_max_err_baseline_m"])), ("ProjQOneFixedCoverBase", percent(examples["coverage_accuracy_baseline"])), ("ProjQOneFixedMsOpt", fixed(examples["runtime_optimal_mean_ms"])), ("ProjQOneFixedMsBase", fixed(examples["runtime_baseline_mean_ms"])), ("ProjQOneRandStatusBase", percent(random["status_accuracy_baseline"])), ("ProjQOneRandMaeBase", fixed(random["diameter_mae_baseline_m"])), ("ProjQOneRandMaeMaxBase", fixed(random["diameter_max_err_baseline_m"])), ("ProjQOneRandCoverBase", percent(random["coverage_accuracy_baseline"])), ("ProjQOneRandMsOpt", fixed(random["runtime_optimal_mean_ms"])), ("ProjQOneRandMsBase", fixed(random["runtime_baseline_mean_ms"])), ("ProjQTwoCases", str(two["optimal"]["cases"])), ("ProjQTwoInitialRadius", fixed(two["_initial_radius_mean_m"])), ("ProjQTwoGuaranteeOpt", percent(two["optimal"]["guaranteed_rate"])), ("ProjQTwoGuaranteeMec", percent(two["baseline_mec"]["guaranteed_rate"])), ("ProjQTwoGuaranteePerp", percent(two["baseline_perp"]["guaranteed_rate"])), ("ProjQTwoWorstOpt", fixed(two["optimal"]["worst_posterior_mean_m"])), ("ProjQTwoWorstRef", fixed(two["reference"]["worst_posterior_mean_m"])), ("ProjQTwoRatioOpt", fixed(two["optimal"]["final_ratio_mean"])), ("ProjQTwoRatioMec", fixed(two["baseline_mec"]["final_ratio_mean"])), ("ProjQTwoWorstPerp", fixed(two["baseline_perp"]["worst_posterior_mean_m"])), ("ProjQTwoWorstMec", fixed(two["baseline_mec"]["worst_posterior_mean_m"])), ("ProjQThreeScenes", str(three["optimal"]["episodes"])), ("ProjQThreeMeanOpt", fixed(three["optimal"]["virtual_time_mean_s"])), ("ProjQThreeMeanSeven", fixed(three["seven"]["virtual_time_mean_s"])), ("ProjQThreeMeanBase", fixed(three["baseline"]["virtual_time_mean_s"])), ("ProjQThreeDropOpt", percent(-paired3["optimal_vs_baseline"]["mean_relative_change"])), ("ProjQThreeDropMedianOpt", percent(-paired3["optimal_vs_baseline"]["median_relative_change"])), ("ProjQThreeDropSeven", percent(-paired3["seven_vs_baseline"]["mean_relative_change"])), ("ProjQThreeFasterOpt", f"{paired3['optimal_vs_baseline']['faster_scenes']}/{paired3['optimal_vs_baseline']['compared']}"), ("ProjQThreeMeasuresOpt", fixed(three["optimal"]["measures_mean"])), ("ProjQThreeMeasuresBase", fixed(three["baseline"]["measures_mean"])), ("ProjQThreeMoveOpt", fixed(three["optimal"]["move_distance_mean_m"] / 1000)), ("ProjQThreeMoveBase", fixed(three["baseline"]["move_distance_mean_m"] / 1000)), ("ProjQThreeWallOpt", fixed(three["optimal"]["compute_wall_mean_s"])), ("ProjQFourScenes", str(four["optimal"]["episodes"])), ("ProjQFourMeanOpt", fixed(four["optimal"]["virtual_time_mean_s"])), ("ProjQFourMeanGrid", fixed(four["baseline_grid"]["virtual_time_mean_s"])), ("ProjQFourMeanBase", fixed(four["baseline"]["virtual_time_mean_s"])), ("ProjQFourClearOpt", percent(four["optimal"]["cleared_ratio_mean"])), ("ProjQFourClearGrid", percent(four["baseline_grid"]["cleared_ratio_mean"])), ("ProjQFourClearBase", percent(four["baseline"]["cleared_ratio_mean"])), ("ProjQFourAbsentBase", str(four["baseline"]["absent_misjudged_total"])), ("ProjQFourMeasuresOpt", fixed(four["optimal"]["measures_mean"])), ("ProjQFourMeasuresBase", fixed(four["baseline"]["measures_mean"])), ("ProjQFourFallbackOpt", fixed(four["optimal"]["fallback_clear_attempts_mean"])), ("ProjQFourValidOpt", f"{four['optimal']['valid_episodes']}/{four['optimal']['episodes']}"), ("ProjQFourValidGrid", f"{four['baseline_grid']['valid_episodes']}/{four['baseline_grid']['episodes']}"), ("ProjQFourRiseOpt", percent(paired4["optimal_vs_baseline"]["mean_relative_change"])), ("ProjQFourMoveOpt", fixed(four["optimal"]["move_distance_mean_m"] / 1000)), ("ProjQFourMoveBase", fixed(four["baseline"]["move_distance_mean_m"] / 1000)), ] lines = [head] + [f"\\newcommand{{\\{name}}}{{{value}}}" for name, value in macros] write_tex(out / "project-macros.tex", "\n".join(lines) + "\n") def write(stem: str, rows: list[str]) -> None: write_tex(out / stem, "\n".join([head, *rows]) + "\n") write("project-q1-summary-rows.tex", [ f"最优(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_optimal'])} & " f"{fixed(examples['diameter_mae_optimal_m'])} & {fixed(examples['diameter_max_err_optimal_m'])} & " f"{percent(examples['coverage_accuracy_optimal'])} & {fixed(examples['runtime_optimal_mean_ms'])} " + r"\\", f"最优(随机 {random['cases']} 例) & {percent(random['status_accuracy_optimal'])} & " f"{fixed(random['diameter_mae_optimal_m'])} & {fixed(random['diameter_max_err_optimal_m'])} & " f"{percent(random['coverage_accuracy_optimal'])} & {fixed(random['runtime_optimal_mean_ms'])} " + r"\\", f"基线(固定 {examples['cases']} 例) & {percent(examples['status_accuracy_baseline'])} & " f"{fixed(examples['diameter_mae_baseline_m'])} & {fixed(examples['diameter_max_err_baseline_m'])} & " f"{percent(examples['coverage_accuracy_baseline'])} & {fixed(examples['runtime_baseline_mean_ms'])} " + r"\\", f"基线(随机 {random['cases']} 例) & {percent(random['status_accuracy_baseline'])} & " f"{fixed(random['diameter_mae_baseline_m'])} & {fixed(random['diameter_max_err_baseline_m'])} & " f"{percent(random['coverage_accuracy_baseline'])} & {fixed(random['runtime_baseline_mean_ms'])} " + r"\\", ]) def variant_rows(table: dict, mapping: list[tuple[str, str]]) -> list[str]: return [ f"{label} & {table[key]['valid_episodes']}/{table[key]['episodes']} & " f"{percent(table[key]['cleared_ratio_mean'])} & {table[key]['absent_misjudged_total']} & " f"{fixed(table[key]['virtual_time_mean_s'])} & {fixed(table[key]['virtual_time_median_s'])} & " f"{fixed(table[key]['virtual_time_max_s'])} & {fixed(table[key]['measures_mean'], 1)} & " f"{fixed(table[key]['move_distance_mean_m'] / 1000)} & {fixed(table[key]['fallback_clear_attempts_mean'], 1)} " + r"\\" for key, label in mapping ] def table_environment(caption: str, label: str, colspec: str, header: str, body: list[str], placement: str = "htbp", tabcolsep: str = "4pt") -> str: """整表输出:表格不能被 ``\\input`` 切开,否则注释行会在表内注入 ``\\par``。""" return "\n".join([ f"\\begin{{table}}[{placement}]", "\\centering\\small", f"\\caption{{{caption}}}", f"\\setlength{{\\tabcolsep}}{{{tabcolsep}}}", f"\\begin{{tabular}}{{{colspec}}}", "\\toprule", header, "\\midrule", *body, "\\bottomrule", "\\end{tabular}", f"\\label{{{label}}}", "\\end{table}", "", ]) write("project-q3-table.tex", [table_environment( f"问题三:三种策略在 {three['optimal']['episodes']} 个全向源场景上的对比(时间:s;" f"三种策略的缺席误判均为 {three['optimal']['absent_misjudged_total']})", "tab:q3compare", "lrrrrrrrr", "策略 & 有效局 & 全清率 & $T$ 均值 & $T$ 中位 & $T$ 最大 & 检测均值 & " "位移/km & 后备清除" + r"\\", # 问题三三种策略的缺席误判均为 0,该列改写入表题以控制表宽。 [" & ".join(c for i, c in enumerate(row.split(" & ")) if i != 3) + r" \\" for row in variant_rows(three, [ ("optimal", "bayes(默认策略)"), ("seven", "seven(七点批量)"), ("baseline", "grid(49 点网格)")])], tabcolsep="2pt")]) write("project-q4-table.tex", [table_environment( f"问题四:三种策略在 {four['optimal']['episodes']} 个混合源场景上的对比(时间:s)", "tab:q4compare", "lrrrrrrr", "策略 & 有效局 & 全清率 & 缺席误判 & $T$ 均值 & $T$ 中位 & $T$ 最大 & 检测均值" + r"\\", # 问题四表去掉位移与后备清除两列(正文单独说明后备清除次数),以控制表宽。 [" & ".join(row.split(" & ")[:8]) + r" \\" for row in variant_rows(four, [ ("optimal", "directional(默认策略)"), ("baseline_grid", "grid(问题三网格)"), ("baseline", "bayes(问题三策略)")])], tabcolsep="4pt")]) def listings(rows: list[dict]) -> None: """生成附录的正式数据表格;核心函数源码清单由 build_code_appendix.py 单独生成。 论文附录只收录四问主线的核心函数(数据源为单文件核心代码 ipynb), 评测机、命令行外壳、采集/绘图脚本、测试与未采用方案不再逐字排版。 正式结果表同样整表输出:被 ``\\input`` 的文件若以注释行开头, 会在表格内部注入 ``\\par``,使 booktabs 的 ``\\bottomrule`` 报错。 """ data = PAPER / "data" head = ("% Generated by scripts/build_paper_support.py; 本文件是完整 table 环境," "供附录直接 \\input。") table = [ r"\begin{longtable}{p{1.15cm}p{13.3cm}}", r"\caption{所选正式导出文件与完整包哈希}\label{tab:packages}\\", r"\toprule 案例 & 原始文件名及 SHA-256\\\midrule\endfirsthead", r"\toprule 案例 & 原始文件名与 SHA-256\\\midrule\endhead", ] for row in rows: h = row["package_sha256"] label = f"Q{row['problem_no']}-{row['formal_index']}" table += [ fr"{label} & \pathtext{{{Path(row['file']).name}}}\\", fr"& {{\scriptsize\texttt{{{h[:32]}}}\allowbreak\texttt{{{h[32:]}}}}}\\[4pt]", ] table += [r"\bottomrule\end{longtable}"] write_tex(data / "formal-file-list.tex", "\n".join(table) + "\n") def formal_table(caption: str, label: str, header: str, body: list[str]) -> str: return "\n".join([ r"\begin{table}[htbp]", r"\centering\small", f"\\caption{{{caption}}}", r"\setlength{\tabcolsep}{4pt}", r"\begin{tabularx}{\linewidth}{l X r r r}", r"\toprule", header, r"\midrule", *body, r"\bottomrule", r"\end{tabularx}", f"\\label{{{label}}}", r"\end{table}", "", ]) formal_head = r"测试 & 测试案例编码 & 清除数 & 总虚拟时间/s & 程序运行时间/s\\" for problem in (3, 4): subset = sorted((r for r in rows if r["problem_no"] == problem), key=lambda r: r["formal_index"]) body = [ f"测试 {r['formal_index']} & \\texttt{{{r['case_code']}}} & " f"{r['cleared_jammer_count']} & {fixed(r['virtual_time_s'])} & " f"{fixed(r['program_run_duration_s'])} " + r"\\" for r in subset ] write_tex(data / f"formal-q{problem}-table.tex", "\n".join([head, formal_table( f"问题{'三四'[problem - 3]}正式测试结果(时间单位:s)", f"tab:formal{problem}", formal_head, body)])) body = [] for r in sorted(rows, key=lambda r: (r["problem_no"], r["formal_index"])): body.append( f"Q{r['problem_no']}-{r['formal_index']} & {r['measure_accepted_count']} & " f"{r['channel_switch_count']} & {r['clear_failure_count']} & " f"{fixed(r['virtual_time_s'])} & {fixed(r['inferred_movement_time_s'])} & " f"{percent(r['movement_share_percent'] / 100)} " + r"\\") write_tex(data / "formal-cost-table.tex", "\n".join([ head, r"\begin{table}[htbp]", r"\centering\small", r"\caption{六次正式测试的动作计数与移动耗时}", r"\setlength{\tabcolsep}{4.5pt}", r"\begin{tabular}{lrrrrrr}", r"\toprule", r"案例 & 检测数 & 换频数 & 失败清除 & 总虚拟时间/s & 移动时间/s & 移动占比\\", r"\midrule", *body, r"\bottomrule", r"\end{tabular}", r"\label{tab:formalcost}", r"\end{table}", ""])) SUPPORT_README = """# 论文支撑材料 汇总自建模拟器(评测机 `referee/`)的演练局日志与官方模拟器 `test` 中的六份正式导出。 正文的机制论证与定量结论来自演练局,官方正式数据只用于附录中的效果验证。 ## 环境和运行 Python >=3.10;安装 requests,可选 numpy 加速。 在本目录使用 PowerShell: $env:PYTHONPATH = (Resolve-Path src).Path python -m mcm26b.cli --help python -m mcm26b.cli run --problem 3 --strategy bayes --robot-id <模拟器登录队号> python -m mcm26b.cli run --problem 4 --strategy directional --robot-id <模拟器登录队号> 在线命令需要先由使用者在官方模拟器中选择测试、待接口就绪。 自建模拟器可用 `python -m referee` 启动,演练局日志写入 `referee-data/sessions/`。 ## 重算论文数据和图表 python scripts/build_practice_data.py # 演练局复算,生成正文数据与宏文件 python scripts/plot_paper_figures.py # 生成正文全部插图 python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json python scripts/build_paper_support.py # 附录正式数据表与支撑包 第一条命令按题面计时模型复算 `referee-data/sessions/` 下的每局演练日志, 输出论文引用的宏、表格与插图数据;复算值与日志记录的最大偏差为 0.000000 s。 正式统计快照来自官方统计队列,通过票据哈希匹配所选包; 重新验证来源匹配需原电脑的统计队列。 连续覆盖与混合类型推断的扩展见论文未来工作。 ## 编译 安装 TeX Live(XeLaTeX、ctex、TikZ),进入 docs/paper: latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex 版式使用原项目模板。编译前请将原项目 docs/paper/fonts 目录放回 同一相对位置。使用 latexmk 自动完成交叉引用所需的多遍编译。 `data/*.tex` 由脚本生成,一律使用 LF 换行:CRLF 会让 `\\input` 的表格行 在 TeX 缓冲中提前 `\\par` 化,触发 booktabs 的 "Misplaced \\noalign"。 support-manifest.json 列出包内全部材料(清单自身除外)及 SHA-256。 """ def package(rows: list[dict]) -> Path: selected = set((ROOT / "src/mcm26b").rglob("*.py")) selected.update((ROOT / "src/mcm26b/guarantee/plans").glob("*.json")) selected.update((PAPER / "data").rglob("*")) selected.update((PAPER / "figures").glob("*.tex")) selected.update((PAPER / "sections").glob("*.tex")) selected.update((ROOT / "docs/figures").glob("*.png")) selected.update((ROOT / "docs/figures").glob("*.json")) selected.add(ROOT / "docs/figures/README.md") selected.update(path for path in (ROOT / "project").rglob("*") if path.is_file() and "__pycache__" not in path.parts) # 自建模拟器与其行为日志是正文数据的来源,必须随支撑包提交。 selected.update(path for path in (ROOT / "referee").rglob("*") if path.is_file() and "__pycache__" not in path.parts and ".pytest_cache" not in path.parts) selected.update((ROOT / "referee-data/sessions").glob("*/*.json*")) # 被排除的演练局也随包提交:论文附录用它们说明"未优化完全"的判定依据。 selected.update((ROOT / "referee-data/rejected-sessions").glob("*/*.json*")) selected.update((ROOT / "referee-data/superseded-sessions").glob("*/*.json*")) selected.update((ROOT / "examples/replay").glob("*.jsonl")) selected.update([PAPER / "main.tex", PAPER / "mcm26b-paper.cls", PAPER / "README.md", PAPER / "REWRITE_NOTES.md", PAPER / "data/formal-macros.tex", ROOT / "mcm26b-核心代码.ipynb", ROOT / "scripts/audit_paper_formal.py", ROOT / "scripts/build_code_appendix.py", ROOT / "scripts/build_practice_data.py", ROOT / "scripts/run_practice_episodes.py", ROOT / "scripts/build_paper_support.py"]) for row in rows: path = ROOT / "Jammers-simulator-full" / row["file"] if digest(path.read_bytes()) != row["package_sha256"]: raise ValueError("Original formal package changed") selected.add(path) blobs = {p.relative_to(ROOT).as_posix(): p.read_bytes() for p in sorted(selected) if p.is_file()} blobs["README.md"] = SUPPORT_README.encode("utf-8") blobs["requirements.txt"] = b"requests>=2.31\nnumpy>=1.24\n" manifest = [{"file": name, "bytes": len(value), "sha256": digest(value)} for name, value in sorted(blobs.items())] blobs["support-manifest.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8") out = PAPER / "support-materials.zip" with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive: for name, value in sorted(blobs.items()): entry = zipfile.ZipInfo(name, (2026, 9, 13, 0, 0, 0)) entry.compress_type = zipfile.ZIP_DEFLATED archive.writestr(entry, value) if out.stat().st_size > 20_000_000: raise ValueError("Support archive exceeds 20 MB") print(f"Support archive: {len(blobs)} files, {out.stat().st_size} bytes") return out def formal_rows() -> list[dict]: """读正式测试快照。 表格需要逐局的移动时间与位移占比,这两项只在 ``formal-results.csv`` 中, 哈希与文件名清单所需的 ``package_sha256`` 则来自 JSON 快照,故两者合并。 """ records = json.loads((PAPER / "data/formal-results.json").read_text("utf-8"))["records"] numbers = {r["case_code"]: r for r in csv_rows()} merged = [] for record in records: extra = numbers.get(record["case_code"], {}) merged.append({**record, **{k: v for k, v in extra.items() if k not in record}}) return merged def csv_rows() -> list[dict]: """按 CSV 表头类型读回 ``formal-results.csv``。""" numeric_int = ("problem_no", "formal_index", "cleared_jammer_count", "measure_accepted_count", "channel_switch_count", "clear_failure_count") numeric_float = ("virtual_time_s", "program_run_duration_s", "inferred_movement_time_s", "movement_share_percent") rows = [] with (PAPER / "data/formal-results.csv").open(encoding="utf-8-sig") as handle: for row in csv.DictReader(handle): for key in numeric_int: row[key] = int(row[key]) for key in numeric_float: row[key] = float(row[key]) rows.append(row) return rows def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--package", action="store_true") args = parser.parse_args() rows = formal_rows() figures(rows) project_tables() listings(rows) if args.package: package(rows) if __name__ == "__main__": main()