"""One self-contained HTML report (Simplified Chinese, tables only, no external resources) from the analysis JSONs. python report.py --out report.html [--inputs inputs.json] --summary "最终臂=runs/autorun-X/SUMMARY.md" ... \ --val-run "基线·三字段·spread=S.json" ... --val-compare "最终 vs 基线·三字段·spread·全部对象=C.json" ... \ [--val-control "最终 vs 对照·三字段·spread·全部对象=C.json" ...] --val-decode "最终·三字段:spread vs argmax=C.json" ... \ [--test-run ... --test-compare ... --test-control ... --test-decode ...] [--llm L.json] \ [--ablation "最终=ablation/report.json" ... --ablation-sample sample.json] \ [--selection-cohort "最终(select2)=validation-X-all.jsonl" ...] [--reference "最终=DIR" ...] \ [--walltime walltime.tsv] [--notes NOTES.md] Inputs: stratify.py / compare.py / llm_compare.py outputs, diag_ablation.py report.json, roomgenbench --reference-check JSONs (one directory per checkpoint), the autopilot SUMMARY.md files (``[LABEL=]PATH``), run_plan's walltime.tsv and isambard_inputs.py's inputs.json (the two arms, which one is final and why, data / model provenance, missing LLM modes). Every number and every "all / none" statement shown is read from these files. Decisions come ONLY from the validation comparisons (``--val-compare``: final vs baseline; ``--val-control``: final vs control, the evidence for the choice of the final arm; ``--val-decode``: spread vs argmax). Test, LLM, ablation and RoomGenBench sections are labelled report-only and never feed a decision. Significance: primary = paired room-bootstrap 95% CI of the mean per-room difference (direction from it); robustness = Holm-corrected sign test; disagreements are flagged; pooled object-weighted differences are descriptive only. A final-vs-baseline comparison whose two sides carry the same checkpoint is flagged as a placeholder (and "differences are 0" when both sides are also the same evaluate output). A ``--selection-cohort`` label starts with 最终 or 基线, the checkpoint that cohort selected; the selection-bias note names a direction only when one checkpoint's cohorts hold the validation scenes and the other's were given and do not. """ from __future__ import annotations import argparse from collections import Counter import html import json import math from pathlib import Path import re NAMES = {"bottom_center_error_m": "位置误差 (m) ↓", "log_size_error": "log 尺寸误差 ↓", "yaw_error_rad": "朝向误差 (rad) ↓", "bev_iou": "BEV IoU ↑", "log_size_error_plain_convention": "log 尺寸误差·原约定 ↓", "yaw_error_rad_plain_convention": "朝向误差·原约定 (rad) ↓", "object_collision_rate": "物体碰撞率 ↓", "object_hard_violation_rate": "物体硬约束违例率 ↓", "gt_object_collision_rate": "真值物体碰撞率", "room_clean_rate": "洁净房间率(有布局)↑", "room_collision_rate": "有碰撞房间率 ↓", "gt_room_clean_rate": "真值洁净房间率", "gt_room_collision_rate": "真值有碰撞房间率", "interface_pass_rate": "接口通过率 ↑", "strict_ok_rate": "严格几何通过率 ↑", "clean_room_rate_incl_failures": "洁净房间率(失败计不洁净)↑", "baseline:room_center_position": "基线·房间中心 (m)", "baseline:category_mean_position": "基线·类别均值位置 (m)", "baseline:category_median_size": "基线·类别中位尺寸", "baseline:uniform_yaw": "基线·均匀随机朝向 (rad)", "cell_ce": "格子交叉熵 ↓", "cell_entropy": "预测格子熵", "p_gt": "p(真值格子) ↑", "argmax_hit": "argmax 命中率 ↑", "xy_err_m": "argmax XY 误差 (m) ↓", "size_err": "尺寸误差 ↓", "yaw_ce": "朝向分类交叉熵 ↓", "yaw_err_rad": "朝向误差 (rad) ↓", "gt_rank": "真值格子名次 ↓", "cell_tv_vs_a": "格子分布 TV 距离(相对 a)", "yaw_tv_vs_a": "朝向分布 TV 距离(相对 a)"} KINDS = {"rule": "目标选择规则", "place": "声明位置", "family": "来源族", "objects": "物体数分箱", "all": "全部"} RULE_NAMES = {"frozen_prep": "frozen_prep(旧对象)", "wall_anchor": "wall_anchor(新·墙面)", "support_inside_parent": "support_inside_parent(新·内层)", "support_on_added_parent": "support_on_added_parent(新·新增父物体上)"} ABLATION = {"b": "b 房间尺寸换成另一房间的", "c": "c 房间类型换成另一房间的", "d": "d 描述换成类别名", "e": "e 请求顺序打乱"} MODES = {"prompt": "单次询问", "harness": "修复循环(校验后带问题清单重问)", "structured": "单次询问(结构化提示)", "structured-harness": "修复循环(结构化提示,校验后重问)"} CAUSES = {"IncompleteRead": "中继传输错误(HTTP 响应体读取不完整,IncompleteRead):传输层失败,不是模型给出的无效布局", "over_capacity": "超出模型容量 max_objects", "other": "其他推理失败"} VERDICT = {"a_better": ("A 更好", "good"), "a_worse": ("A 更差", "bad"), None: ("无显著差异", "")} CSS = """ :root{--bg:#fbfbfa;--fg:#1d1d1f;--muted:#6b6b70;--line:#e2e2e0;--card:#fff;--accent:#2f5d9e;--good:#1f7a4d;--bad:#a8322d;--warn:#8a5a00;--warnbg:#fff6e0} @media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#151517;--fg:#ececee;--muted:#a0a0a8;--line:#333338;--card:#1d1d20;--accent:#8fb3ec;--good:#6cc79a;--bad:#ef8b85;--warn:#e7c06a;--warnbg:#2b2416}} :root[data-theme="dark"]{--bg:#151517;--fg:#ececee;--muted:#a0a0a8;--line:#333338;--card:#1d1d20;--accent:#8fb3ec;--good:#6cc79a;--bad:#ef8b85;--warn:#e7c06a;--warnbg:#2b2416} *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",sans-serif} main{max-width:1100px;margin:0 auto;padding:24px 16px 64px}h1{font-size:1.6em;margin:.2em 0}h2{font-size:1.25em;margin-top:2.2em;border-bottom:1px solid var(--line);padding-bottom:.3em} h3{font-size:1.05em;margin-top:1.6em}p,li{max-width:80ch}.muted{color:var(--muted)}code{font-size:.9em;background:var(--card);border:1px solid var(--line);border-radius:4px;padding:0 .3em;word-break:break-all} .wrap{overflow-x:auto;margin:.6em 0 1.2em;border:1px solid var(--line);border-radius:8px;background:var(--card)} table{border-collapse:collapse;width:100%;font-size:.88em;font-variant-numeric:tabular-nums}th,td{padding:.45em .7em;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap} th:first-child,td:first-child{text-align:left}thead th{background:var(--bg);font-weight:600}tr:last-child td{border-bottom:0} .ci{color:var(--muted);font-size:.85em}.good{color:var(--good);font-weight:600}.bad{color:var(--bad);font-weight:600} .flag{background:var(--warnbg);color:var(--warn);border-left:3px solid var(--warn);padding:.6em .9em;border-radius:4px;margin:1em 0} .tag{display:inline-block;font-size:.75em;font-weight:600;padding:.05em .5em;border-radius:4px;border:1px solid currentColor;margin-left:.5em;vertical-align:middle} details{margin:.8em 0}summary{cursor:pointer;color:var(--accent)}nav a{color:var(--accent);margin-right:1em;text-decoration:none} """ REPORT_ONLY = "仅报告,不参与决策" DECISION = "决策依据:验证集" def esc(value): return html.escape(str(value)) def num(x, digits=3): if x is None or (isinstance(x, float) and not math.isfinite(x)): return "—" return f"{x:.{digits}g}" if abs(x) and abs(x) < 1e-3 else f"{x:.{digits}f}" def cell(stat, digits=3): """mean [lo, hi] from {"mean", "ci95"} or [mean, lo, hi, ...].""" if stat is None: return "—" mean, lo, hi = (stat["mean"], *stat["ci95"]) if isinstance(stat, dict) else stat[:3] return f'{num(mean, digits)} [{num(lo, digits)}, {num(hi, digits)}]' def pval(p): return "—" if p is None else "<1e-300" if p == 0 else f"{p:.2g}" def table(head, rows): out = "
| {h} | " for h in head) + "
|---|
| {c} | " for c in row) + "
\1", esc(s)))
out, lines, i = [], text.splitlines(), 0
while i < len(lines):
line = lines[i]
if line.startswith("#"):
level = min(len(line) - len(line.lstrip("#")) + 2, 6)
out.append(f"{inline(line)}
") i += 1 return "\n".join(out) def load(spec): label, _, path = spec.rpartition("=") # labels may hold "=", paths do not return label, json.loads(Path(path).read_text()), path def short_ckpt(path): return f"{Path(path).parent.name}/{Path(path).name}" if path else "—" def rows_info(path, _cache={}): """(scene ids, objects whose selection_rule is not frozen_prep) of a rows JSONL file.""" if path not in _cache: ids, new = set(), 0 with open(path) as stream: for line in stream: if line.strip(): prov = json.loads(line)["provenance"] ids.add(prov.get("scene_id")) new += sum(((e or {}).get("selection_rule") or "frozen_prep") != "frozen_prep" for e in prov.get("field_evidence") or []) _cache[path] = ids, new return _cache[path] def scene_ids(path): return rows_info(path)[0] def overlaps(ids, cohorts): """(label, path, scenes of `ids` in the cohort) for every cohort holding any of them.""" return [(label, path, len(ids & scene_ids(path))) for label, path in cohorts if ids & scene_ids(path)] def cohort_flags(what, ids, cohorts): return "".join(f"{esc(Path(path).name)}({len(scene_ids(path))} 个场景,非 frozen_prep 规则物体 {rows_info(path)[1]} 个)内:"
"这些行参与了挑选该检查点,该检查点在这些行上不是留出评测。{esc(short_ckpt(m['checkpoint']))}",
f"{esc(Path(m['rows']).parent.name)}/{esc(Path(m['rows']).name)}", esc(m["projection"]),
esc(m.get("grid_decode") or "—"), "头输出重解码" if m.get("head_outputs") else esc(m.get("baseline") or "—"),
f"{esc((m.get('data_sha256') or '')[:12])}",
f"{esc((m.get('forward_implementation_sha256') or '—')[:12])}",
f"{esc((m.get('implementation_sha256') or '—')[:12])}",
f"{c['requests_with_layout']}/{c['requests']}",
'是' if c["reproduces_report_exactly"] else '否',
str(c["rooms_validation_differs_from_stored"])])
return table(["集合", "运行", "检查点", "数据", "投影", "解码", "来源", "数据 sha256", "前向代码", "解码/评分代码",
"有布局/请求", "逐位复现 report.json", "校验器漂移房间"], rows)
def overall(s, metric):
return s["strata"]["all"]["all"]["metrics"].get(metric)
def placeholder(c):
"""A final-vs-baseline comparison whose two sides carry the same checkpoint."""
return c["meta"]["a"].get("checkpoint") == c["meta"]["b"].get("checkpoint")
def same_output(c):
"""Both sides are the same evaluate output: every difference is 0."""
return all(c["meta"]["a"].get(k) == c["meta"]["b"].get(k) for k in ("eval_dir", "outcomes"))
def verdict_cell(v, a="A"):
text, cls = VERDICT[v["verdict"]["primary"]]
return f'{text.replace("A", esc(a))}' if cls else text
def compare_table(c, final_vs_baseline=False):
a, b = c["meta"]["a"]["label"], c["meta"]["b"]["label"]
rows = []
for metric, v in c["metrics"].items():
st, lo, hi = v["sign_test"], *v["room_mean_diff_ci95"]
robust = VERDICT[v["verdict"]["sign_test"]][0]
rows.append([NAMES.get(metric, metric), num(v["a"]), num(v["b"]), num(v.get("a_room_mean")), num(v.get("b_room_mean")),
f"{num(v['room_mean_diff'], 4)} [{num(lo, 4)}, {num(hi, 4)}]", verdict_cell(v),
f"{st['a_greater']}/{st['b_greater']}/{st['ties']}", pval(st["p_holm"]),
robust if v["verdict"]["agree"] else f"⚠ 不一致:{robust}",
f"{num(v['pooled_diff'], 4)} [{num(v['pooled_diff_ci95'][0], 4)}, {num(v['pooled_diff_ci95'][1], 4)}]",
str(v["rooms"])])
r, where = c["rooms"], c["meta"].get("where") or {}
note = (f"A = {esc(a)},B = {esc(b)};差值 = A − B;合并 = 物体加权均值,房间均值 = 先在房间内平均再对房间平均" f"(A、B 房间均值之差即主判据的房间均值差)。房间 {r['compared']}" + (f"(另有 {r['excluded_by_room_filter']} 个房间因物体数 > {c['meta']['max_objects']} 被排除)" if c["meta"].get("max_objects") else "") + f":两者都有布局 {r['layout_both']},仅 A 有布局 {r['layout_only_a']},仅 B 有布局 {r['layout_only_b']},都没有 {r['layout_neither']};" "配对指标只在两者都有布局的房间上计算" + (f";筛选 {esc(where)}" if where else "") + f";bootstrap {c['meta']['boot']} 次(房间为单位,配对)。
") if final_vs_baseline and placeholder(c): note += (f"占位:A、B 是同一个检查点 {esc(short_ckpt(c['meta']['a'].get('checkpoint')))},差值不说明最终模型"
+ (";A 与 B 也是同一个评测输出:差值必为 0" if same_output(c) else "") + "。
A 与 B 是同一个评测输出:差值必为 0。
" if not rows: return note + "没有符合筛选条件的物体(例如旧数据没有新增对象),无可比较的指标。
" return note + table(["指标", "A(合并,物体加权)", "B(合并,物体加权)", "A 房间均值", "B 房间均值", "房间均值差 [95% CI](主判据)", "结论(主判据)", "符号检验 A>B/A<B/平", "p (Holm)", "稳健性:符号检验", "合并差值 [95% CI](物体加权,仅描述)", "房间"], rows) def strata_tables(s, metrics): out = [] for kind in ("rule", "place", "family", "objects"): entries = s["strata"].get(kind, {}) rows = [[esc(RULE_NAMES.get(v, v)), str(e["rooms"]), str(e.get("rooms_with_layout", "—")), str(e["objects"])] + [cell(e["metrics"].get(m)) for m in metrics] for v, e in entries.items()] out.append(f"检查点 {esc(a['checkpoint'])};{a['rows']} 个验证房间"
f"({prior_a['cell_ce'][3]} 个位置可评物体);改变的房间 b={a['changed_rooms']['b']} c={a['changed_rooms']['c']};"
f"判据交叉核对最大相对误差 {num(a['criterion_crosscheck_max_rel_err'], 2)};均匀分布交叉熵 {num(a['prior']['uniform_ce'])}。
变体减 a(95% CI,房间 bootstrap):
" + table(["变体"] + [NAMES[k] for k in keys], drows) + f"模型格子交叉熵减输入无关先验(训练集频率,{a['prior']['train_rows']} 行):最接近模型的先验 {esc(best[0])}"
f" 差值 {cell(best[1], 4)};负值 = 模型优于该先验。{len(prior)} 种先验中差值的 CI 上界最大为 "
f"{num(max(v[2] for v in prior.values()), 4)}。
{esc(Path(c['file']).name)}({c['cohort_rows']} 个场景)里:用该队列选出的 FastFill 检查点在这些行上不是留出评测,LLM 智能体不受影响。无布局的房间及原因(LLM 取自 predictions.jsonl 的 error 字段;名次 1 = 物体最多,并列取同一名次):
{esc(Path(c['latency_source']).name)}") \
+ f"({esc(c.get('latency_grid_decode') or '—')} 解码)"
out.append("LLM 延迟取自 predictions.jsonl 的逐房间 latency_s(全部房间的均值即 summary.json 的 mean_latency_s):" "每房间全部 API 调用的墙钟时间,含客户端限速等待、重试与退避(修复循环还含修复调用)。FastFill 延迟为在线评测记录的 fastfill_latency_ms" "(一次 GPU 前向 + 解码):" + ";".join(f"{esc(m)} 取自{source(c)}" for m, c in cost.items() if c["kind"] == "fastfill") + "。“仅有布局房间”只平均给出布局的房间。" + (f"summary 未记录 token 用量的方法:{esc('、'.join(no_tokens))}。" if no_tokens else "") + "
") meta = llm["meta"]["methods"] sha = lambda x: f"{esc((x or '—')[:12])}"
out.append("{esc(Path(x['eval_dir']).name)}", f"{esc(short_ckpt(x.get('checkpoint')))}",
esc(x.get("grid_decode") or "—"), esc(x.get("baseline") or "—"), sha(x.get("data_sha256")),
sha(x.get("forward_implementation_sha256")), sha(x.get("implementation_sha256")),
'是' if llm["checks"][m] else '否'] for m, x in meta.items()])
+ "退化规则(口径二):{esc(llm['meta']['fallback'])}。
") return "".join(out) def section_arms(inp): """The two autopilot arms, which one is final and why, and where data and models come from (isambard_inputs.py).""" out = ("{esc(x['dir'])}", f"{esc(x['run'])}",
num(x["yaw_cls"], 2), str(x["max_objects"]), str(x["global_batch"]), f"{x['steps']}({num(x['epochs_supervised'])} 轮)",
esc(x["phase"]), esc(";".join(x["failures"]) or "无"), f"{esc(short_ckpt(x['best_checkpoint']))}",
f"{x['best_step']}({num(x['best_epochs'])} 轮)", num(x["best_score"], 4), f"{esc(x['select2_cohort_sha256'][:12])}",
f"{esc(x['select2_implementation_sha256'][:12])}", esc(x.get("uploaded_model") or "—")]
for x in inp["arms"]]
ranking = "".join(f"{esc(short_ckpt(c))} {num(s, 4)}"
for c, s in x["select2_ranking"]) + "规则:{esc(inp['rule'])}
{x}
" for r in val_rows if (x := bias_note(rows_name(r), scene_ids(r), cohorts))) boots = sorted({s["meta"]["boot"] for _, _, s in runs}) parts.append("evaluate.reference_metrics 相同(合法对应匹配、盒等价尺寸/朝向);逐物体数值由 stratify.py 用评测自身的函数重算,并逐请求、逐位核对 outcomes 与 report.json(上表“逐位复现”)。
平凡基线拟合来源:{esc('、'.join(fits))}。分层置信区间:房间为单位的百分位 bootstrap({esc('/'.join(map(str, boots)))} 次)。provenance.field_evidence[i].selection_rule(缺失记为 frozen_prep)。各集合的规则分布(物体数):"""
+ ";".join(f"{split}:" + "、".join(f"{esc(r)} {n}" for r, n in c.items()) for split, c in rules.items() if c) + """。validation.validate_scene(当前代码),模型布局与标签各跑一次;“洁净房间”= 无硬约束违例(unknown 不计违例)。接口通过 = 返回了每个请求 ID 恰好一次、尺寸为正的布局;几何通过分两级:" "严格(所有硬检查 pass,unknown 也算不通过)与洁净(无硬违例)。真值列是同一校验器对标签本身的结果:是同一校验器下的参照," "不是上限(模型可能比标签更洁净)。
" + table(["集合", "运行"] + [NAMES[m] for m in metrics], [[split, esc(l)] + [cell(overall(s, m)) for m in metrics] for split, l, s in runs])) # 3 validation: the decisions' basis sec = f"(未提供验证集比较:本报告不作决策)
" sec += "(未提供)
") sec += "(未提供)
") if val_control: sec += ("平凡基线在同一批有布局的房间、同一批物体上计算。argmax = 模型原始输出;spread = 防碰撞后处理解码。
" if test_runs: sec += (full_set_table([(l, s) for l, s, _ in test_runs]) + results_table([(l, s) for l, s, _ in test_runs]) + "".join(f"(未提供)
") # 5 LLM, 6 ablation, 7 RoomGenBench: report only n_llm = sum(c["kind"] == "llm" for c in llm["cost"]) if llm else 0 missing = ("(未提供)
")) parts.append(f"同一批验证房间(三字段投影)上改动一个输入、其余不变,看格子分布与误差是否随之变化;" "再与只看类别(及房间类型)的训练集频率先验比较格子交叉熵。
" + (section_ablation(ablations, a.ablation_sample, sample, cohorts) or "(未提供)
")) parts.append(f"五个房间、单次预测:没有统计功效,只看量级与失败模式;" "同一校验器也跑在真值上作为参照。
" + (section_reference(refs) or "(未提供)
")) # 8 limitations and decisions lim, seen = [], set() for split, label, s in runs: c, key = s["checks"], (s["meta"]["eval_dir"], s["meta"]["outcomes"]) if key in seen: continue seen.add(key) if not c["reproduces_report_exactly"]: lim.append(f"{split}·{esc(label)}:逐物体重算未逐位复现 report.json,该运行的数字不可直接与报告对照。") if c["rooms_validation_differs_from_stored"]: lim.append(f"{split}·{esc(label)}:{c['rooms_validation_differs_from_stored']} 个房间的当前校验器结果与评测时存储的不同(校验器代码已变);本报告用当前校验器。") if c["requests"] - c["requests_with_layout"]: lim.append(f"{split}·{esc(label)}:{c['requests'] - c['requests_with_layout']} 个请求没有布局(其中 {c['over_capacity_requests']} 个超出模型容量 " "max_objects),误差只在有布局的房间上计算;接口通过率中计为失败。") if any(s["meta"]["projection"] == "minimal" for _, _, s in runs): lim.append("三字段投影不向模型显示支撑声明;“声明位置”分层用的是完整行的声明,作为真值位置标签。") if missing_llm(inp): lim.append(f"第 5 节缺少 LLM 模式 {esc(missing_llm(inp))}(答案不可得):LLM 对比只含已有答案的模式。") if llm: lim += [f"LLM 行与检查点选择队列 {esc(Path(c['file']).name)} 重叠 {c['rows_in_cohort']}/{c['rows']}:用该队列选出的 FastFill 检查点在这些行上不是留出评测。" for c in llm["selection_cohort"] if c["rows_in_cohort"]] lim += [f"第 5 节 {esc(m)}:逐物体重算未逐位复现 report.json,该方法的数字不可直接与其报告对照。" for m, ok in llm["checks"].items() if not ok] lim += [f"第 5 节 {esc(c['method'])}:predictions.jsonl 逐房间延迟的均值 {num(c['mean_latency_s'], 4)} s 与 summary.json 的 mean_latency_s " f"{num(c['summary_mean_latency_s'], 4)} s 不一致。" for c in llm["cost"] if c["kind"] == "llm" and abs(c["mean_latency_s"] - c["summary_mean_latency_s"]) > 1e-6] if ablations and sample and (hits := overlaps(set(sample["scene_ids"]), cohorts)): lim.append(f"{esc(sample_name(a.ablation_sample, sample))}与检查点选择队列重叠:" + "、".join(f"「{esc(l)}」{k}/{len(sample['scene_ids'])}" for l, _, k in hits) + ":这些场景对相应检查点不是留出数据。") family_rows = [] for split, label, s in runs: fam = s["strata"].get("family", {}) worse = [f for f, e in fam.items() if (e["metrics"].get("bottom_center_error_m") or {}).get("mean") is not None and e["metrics"]["bottom_center_error_m"]["mean"] > (e["metrics"].get("baseline:room_center_position") or {}).get("mean", math.inf)] family_rows.append([split, esc(label), f"{len(worse)}/{len(fam)}", esc("、".join(worse)) or "—"]) parts.append("位置误差(点估计)高于房间中心平凡基线的来源族:
" + table(["集合", "运行", "来源族数", "这些来源族"], family_rows) + f"以下两项只读第 3 节验证集比较(主判据 = 房间均值差的配对 bootstrap 95% CI 不含 0,按指标逐个判断," "未做多重比较校正;稳健性 = Holm 校正的符号检验,只看差值方向/中位数而非均值,与主判据不一致可能来自多重比较或偏态分布)。" "测试集与其它各节不进入此处。
" + (f"(未提供验证集比较:不作决策)
") + "(未提供验证集比较:不作决策)
") + ("{esc(inp['rule'])}
" if inp else "") + decision_table(val_control, False) if val_control else "") + (f"