Files
mcm-26-b/scripts/build_paper_support.py
T
2026-09-13 10:52:28 +08:00

377 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Build vector figure sources, comparison tables, source listings, and a support ZIP.
Formal figures use the audited snapshot only. Geometric figures use declared
algorithm parameters, not fabricated observations or hidden simulator data.
The paired optimal-versus-baseline tables are generated from project/results/*.json
so that no number in the comparison section is hand copied.
"""
from __future__ import annotations
import argparse
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"
# Display copies only: executable Python files remain byte-for-byte unchanged.
ESCAPE_OPEN = "(" + "*@"
ESCAPE_CLOSE = "@*" + ")"
LATEX_SYMBOLS = {
"\u03b1": r"\alpha", "\u03b4": r"\delta", "\u03b8": r"\theta",
"\u03bb": r"\lambda", "\u03c0": r"\pi", "\u03c1": r"\rho",
"\u03c6": r"\varphi", "\u2308": r"\lceil", "\u2309": r"\rceil",
"\u2200": r"\forall", "\u2203": r"\exists", "\u2205": r"\varnothing",
"\u2208": r"\in", "\u2227": r"\wedge", "\u2229": r"\cap",
"\u222a": r"\cup", "\u2248": r"\approx", "\u2264": r"\le",
"\u2265": r"\ge", "\u2286": r"\subseteq", "\u2287": r"\supseteq",
"\u27f9": r"\Longrightarrow",
}
def escaped_listing(source: str) -> str:
if ESCAPE_OPEN in source or ESCAPE_CLOSE in source:
raise ValueError("Source collides with the LaTeX escape delimiters")
return "".join(
ESCAPE_OPEN + r"\ensuremath{" + LATEX_SYMBOLS[c] + "}" + ESCAPE_CLOSE
if c in LATEX_SYMBOLS else c for c in source
)
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def figures(rows: list[dict]) -> None:
out = PAPER / "figures"
out.mkdir(parents=True, exist_ok=True)
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 = 3 - 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}",
]
(out / "formal-time-bars.tex").write_text("\n".join(bars) + "\n", "utf-8")
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]
(out / "project-macros.tex").write_text("\n".join(lines) + "\n", "utf-8")
def write(stem: str, rows: list[str]) -> None:
(out / stem).write_text("\n".join([head, *rows]) + "\n", "utf-8")
write("project-overview-rows.tex", [
"问题 & 核心指标 & 最优实现 & 基线实现 " + r"\\",
f"1 & 直径平均绝对误差 / 覆盖判定准确率 & {fixed(random['diameter_mae_optimal_m'])} m / "
f"{percent(random['coverage_accuracy_optimal'])} & {fixed(random['diameter_mae_baseline_m'])} m / "
f"{percent(random['coverage_accuracy_baseline'])} " + r"\\",
f"2 & 保证接收率 / 最坏后验半径均值 & {percent(two['optimal']['guaranteed_rate'])} / "
f"{fixed(two['optimal']['worst_posterior_mean_m'])} m & {percent(two['baseline_mec']['guaranteed_rate'])} / "
f"{fixed(two['baseline_mec']['worst_posterior_mean_m'])} m " + r"\\",
f"3 & 全清率 / 平均虚拟耗时 & {percent(three['optimal']['cleared_ratio_mean'])} / "
f"{fixed(three['optimal']['virtual_time_mean_s'])} s & {percent(three['baseline']['cleared_ratio_mean'])} / "
f"{fixed(three['baseline']['virtual_time_mean_s'])} s " + r"\\",
f"4 & 全清率 / 缺席误判总数 & {percent(four['optimal']['cleared_ratio_mean'])} / "
f"{four['optimal']['absent_misjudged_total']} & {percent(four['baseline']['cleared_ratio_mean'])} / "
f"{four['baseline']['absent_misjudged_total']} " + r"\\",
])
write("project-q1-example-rows.tex", [
f"{row['name']} & {fixed(row['diameter_ref_m'])} & {fixed(row['diameter_opt_m'])} & "
f"{fixed(row['diameter_base_m'])} & {fixed(row['abs_err_base_m'])} & "
f"{flag(row['covers_ref'])} & {flag(row['covers_opt'])} & {flag(row['covers_base'])} " + r"\\"
for row in data["q1"]["examples"]
])
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"\\",
])
write("project-q2-rows.tex", [
f"{label} & {percent(two[key]['guaranteed_rate'])} & {fixed(two[key]['worst_posterior_mean_m'])} & "
f"{fixed(two[key]['worst_posterior_median_m'])} & {fixed(two[key]['improvement_mean_m'])} & "
f"{fixed(two[key]['final_ratio_mean'])} & {fixed(two[key]['move_cost_mean_s'], 2)} " + r"\\"
for key, label in (("optimal", "算法2(默认选点)"), ("reference", "参考:盘内网格枚举"),
("baseline_perp", "基线:垂直 1000 m"), ("baseline_mec", "基线:候选圆心"))
])
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
]
write("project-q3-rows.tex", variant_rows(three, [
("optimal", "bayes(默认策略)"), ("seven", "seven(七点批量)"), ("baseline", "grid(49 点网格)")]))
write("project-q4-rows.tex", variant_rows(four, [
("optimal", "directional(默认策略)"), ("baseline_grid", "grid(问题三网格)"),
("baseline", "bayes(问题三策略)")]))
def listings(rows: list[dict]) -> None:
data = PAPER / "data"
files = sorted((ROOT / "src/mcm26b").rglob("*.py"))
files += [ROOT / "scripts/audit_paper_formal.py",
ROOT / "scripts/build_paper_support.py"]
text = [
r"\begingroup",
r"\linespread{1.0}\selectfont",
r"\lstset{basicstyle=\ttfamily\fontsize{8}{10}\selectfont,numberstyle=\tiny,"
r"aboveskip=6pt,belowskip=8pt,xleftmargin=1.5em,frame=none}",
]
for path in files:
rel = path.relative_to(ROOT).as_posix()
display = data / "listing-source" / (rel + ".tex")
display.parent.mkdir(parents=True, exist_ok=True)
display.write_text(escaped_listing(path.read_text("utf-8")), "utf-8")
display_rel = display.relative_to(PAPER).as_posix()
escape_option = "escapeinside={" + ESCAPE_OPEN + "}{" + ESCAPE_CLOSE + "}"
text += [fr"\subsection*{{\pathtext{{{rel}}}}}",
fr"\lstinputlisting[{escape_option}]{{{display_rel}}}"]
text.append(r"\endgroup")
(data / "source-listings.tex").write_text("\n".join(text) + "\n", "utf-8")
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}"]
(data / "formal-file-list.tex").write_text("\n".join(table) + "\n", "utf-8")
SUPPORT_README = """# 论文支撑材料
只引用官方模拟器 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 <模拟器登录队号>
在线命令需要先由使用者在官方模拟器中选择测试、待接口就绪。
重写论文未运行这些在线命令。自建评测机及历史场景不作为论文实验资料。
策略的自检命令涉及另一个可选 referee 包,本包用于正式接口路径。
## 重算论文数据和图表
python scripts/audit_paper_formal.py --snapshot docs/paper/data/formal-results.json
python scripts/build_paper_support.py
上述命令均为离线处理,不连接模拟器接口。
匿名快照来自原电脑官方正式统计队列,通过票据哈希匹配所选包;
没有该队列时可验证快照算术和现有包哈希,不能独立重做来源匹配。
源码与正式包无策略提交版本哈希绑定;不作同场景消融或全清率宣称。
31点计划只有离散证据;混合类型下的 joint_empty 判定边界见论文正文。
## 编译
安装 TeX Live(XeLaTeX、ctex、TikZ),进入 docs/paper:
latexmk -xelatex -interaction=nonstopmode -halt-on-error main.tex
版式使用原项目模板。精简支撑包不含大体积字体,编译前请将原项目
docs/paper/fonts 目录放回同一相对位置;不要修改模板以替代这些字体。
使用 latexmk 自动完成交叉引用所需的多遍编译,不能只运行一次 XeLaTeX。
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([PAPER / "main.tex", PAPER / "mcm26b-paper.cls",
PAPER / "README.md", PAPER / "REWRITE_NOTES.md",
ROOT / "scripts/audit_paper_formal.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 main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--package", action="store_true")
args = parser.parse_args()
rows = json.loads((PAPER / "data/formal-results.json").read_text("utf-8"))["records"]
figures(rows)
project_tables()
listings(rows)
if args.package:
package(rows)
if __name__ == "__main__":
main()