diff --git a/AGENTS.md b/AGENTS.md index 78165bfed..641eac0f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,7 @@ These requirements apply to **all** report tracks; browser-specific rules below - Run repository Python only through `make` targets or `uv run --frozen python`. `.python-version`, `uv.lock`, the Makefile and CI are the runtime contract; never fall back to system Python. - Run every publishable provider cohort through `scripts/run_certified_benchmark.py`, which freezes references before calls and verifies them again afterward. Direct `run_benchmark.py` output and incomplete cohorts are diagnostic evidence only; never assemble or publish them. - For the default Web Fetch report, treat `evaluation/current-report.json` as the matrix and publication-target authority. The independent WCXB report uses only `evaluation/wcxb/current-report.json` for its matrix and target. Browser Use and Browser Trajectory Replay have separate contracts under their own `evaluation/` namespaces; never reuse a publication target across reports or tracks. Publishing must use the relevant bound site and expected remote version; do not copy target IDs from prose, old receipts or earlier reports. +- For the complete WebMainBench report, `evaluation/webmainbench-full/current-report.json` is the immutable five-method pre-call profile bound by its cohort certificate. The repaired Moli sixth version has its own pre-call profile and certificate at `evaluation/webmainbench-full/moli-fixed-report.json`; the report joins both complete 545-page cohorts by a hash-bound six-method receipt. Their `publication_state` records frozen pre-call state. The live site, share URL, and expected remote version are bound in `evaluation/webmainbench-full/publication-target.json` together with both profile hashes; update this target after publication without changing or re-certifying historical calls. - For the current WCXB report, the source-qualified 504-case manifest is the entire evaluation population for quality, delivery, and latency. Keep source-invalid cases outside the current manifest, scored evidence, public package, and future provider calls; preserve earlier physical calls only in private historical evidence. The dataset page owns frozen task inputs, source URLs and human reference scope; it must not show measured browser scores. The home page owns aggregate comparisons, the result analysis owns the filterable method-case inventory, and case result pages own full responses. Keep a direct link from each dataset row to a safe frozen-page preview and its human reference. Default result filters must show a nonempty, clearly labeled diagnostic population when one exists, and filtered tables must shrink rather than leave a large blank viewport. - Persist provider calls outside Git under `~/.browser-eval/` (or `BROWSER_EVAL_DATA_HOME`). Git may contain compact benchmark definitions, annotations, independent source evidence and public release metadata, but never measured outputs, execution manifests, provider artifacts, rerun directories, credentials or internal work logs. - Web Fetch keeps one current authoritative logical result per method and task. A declared failure retry retains every physical call, retains total retry execution cost separately from the profile-defined primary latency and uses the final response, never the best response. A proven wrong command, version, scorer, reference or timing is a correction: preserve the prior evidence, supersede the complete declared scope and disclose score, denominator and latency effects. diff --git a/evaluation/webmainbench-full/case-correction-binding.json b/evaluation/webmainbench-full/case-correction-binding.json new file mode 100644 index 000000000..5f08b744f --- /dev/null +++ b/evaluation/webmainbench-full/case-correction-binding.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "relative_path": "evidence/webmainbench-545-origin-correction-20260924", + "sha256": "24ec49dba0948b85c0d980b3caf04b016f8fd486ceaa36eef43385b18cb4c9ab" +} diff --git a/evaluation/webmainbench-full/content-binding.json b/evaluation/webmainbench-full/content-binding.json new file mode 100644 index 000000000..44ce3853a --- /dev/null +++ b/evaluation/webmainbench-full/content-binding.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "relative_path": "report-content.json", + "sha256": "c5094f07e653f323a2d4914b69a4533f510fe9e0cb9d7fb75041007ddae6e1cb" +} diff --git a/evaluation/webmainbench-full/current-report.json b/evaluation/webmainbench-full/current-report.json new file mode 100644 index 000000000..457b83388 --- /dev/null +++ b/evaluation/webmainbench-full/current-report.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "dataset_id": "webmainbench-545-full-five-methods", + "comparison_kind": "webmainbench_frozen_html_to_markdown", + "case_count": 545, + "case_ids_sha256": "5c0cf5810602b70e8f7c1e16723e24a49c3ea207d54865df5c03c38f3d88ffd5", + "source_dataset_sha256": "0efaa4b49a45e320a27fe6e5a0b6aad5b57259fc3321ac3448519cacc74c537e", + "providers": ["moli", "lightpanda", "obscura", "chromium", "kitesurf"], + "versions": { + "kitesurf": "Cloudflare hosted stable backend; version undisclosed", + "lightpanda": "0.4.1", + "obscura": "0.2.2", + "moli": "1.1.9", + "chromium": "Google Chrome for Testing 154.0.8037.57" + }, + "timeout_seconds": 45, + "runs_per_case": 1, + "concurrency": 1, + "max_failure_retries": 1, + "reference_binding_mode": "complete_webmainbench_545_official_frozen_html_and_groundtruth_content", + "official_scorer_commit": "9d991bdc00c57b57521499494d96be85c31317ba", + "metric_config": {"use_llm": false}, + "official_metrics": ["overall", "text_edit", "code_edit", "formula_edit", "table_edit", "table_TEDS"], + "input_contract": { + "body": "official html field encoded as UTF-8 without mutation", + "execution": "inline scripts may run; non-loopback subresources and data access are blocked; local engines receive the same body from an OS-isolated loopback HTTPS fixture with a per-run CA, Kitesurf receives the same body through Cloudflare Browser Rendering html input with external resource types rejected", + "original_url": "retained as evidence metadata only" + }, + "publication_state": "pending", + "require_independent_result_review": false, + "conversion": "Native Markdown for Kitesurf, Lightpanda, Obscura and Moli; Chromium whole-DOM HTML via node-html-markdown 2.0.0" +} diff --git a/evaluation/webmainbench-full/failure-analysis-binding.json b/evaluation/webmainbench-full/failure-analysis-binding.json new file mode 100644 index 000000000..d31082508 --- /dev/null +++ b/evaluation/webmainbench-full/failure-analysis-binding.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, + "relative_path": "failure-analysis.json", + "sha256": "b04c476bf39b094260a869fbc42917fa62ad0e3fc3738ac53dcd70235b51bbbc" +} diff --git a/evaluation/webmainbench-full/illustrations.json b/evaluation/webmainbench-full/illustrations.json new file mode 100644 index 000000000..cfa0e22d4 --- /dev/null +++ b/evaluation/webmainbench-full/illustrations.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, + "assets": [ + { + "name": "overview.png", + "sha256": "1166017830d43cf56876e7cfc771d34dcd2b77998cac38d07bb84fa4e869d177", + "generator": "ImageGen", + "scope": "six methods after certified origin correction" + }, + { + "name": "error-mechanisms.png", + "sha256": "c53b86571b5c20976f9a54ccfe33d065111732399fceeb4a148c74c520d3d843", + "generator": "ImageGen", + "scope": "six methods after certified origin correction" + } + ] +} diff --git a/evaluation/webmainbench-full/moli-fixed-report.json b/evaluation/webmainbench-full/moli-fixed-report.json new file mode 100644 index 000000000..d3a31e445 --- /dev/null +++ b/evaluation/webmainbench-full/moli-fixed-report.json @@ -0,0 +1,28 @@ +{ + "schema_version": 1, + "dataset_id": "webmainbench-545-moli-fixed", + "comparison_kind": "webmainbench_frozen_html_to_markdown", + "case_count": 545, + "case_ids_sha256": "5c0cf5810602b70e8f7c1e16723e24a49c3ea207d54865df5c03c38f3d88ffd5", + "source_dataset_sha256": "0efaa4b49a45e320a27fe6e5a0b6aad5b57259fc3321ac3448519cacc74c537e", + "providers": ["moli"], + "versions": {"moli": "1.1.9"}, + "binary_sha256": "48f45a608ce41d3c6600ca88df964e580aecf353289e028e8d9f7703224ab3b1", + "source_commit": "a50182cbe4eef20b230d3f01e452d9de4f3146ba", + "timeout_seconds": 45, + "runs_per_case": 1, + "concurrency": 1, + "max_failure_retries": 1, + "reference_binding_mode": "complete_webmainbench_545_official_frozen_html_and_groundtruth_content", + "official_scorer_commit": "9d991bdc00c57b57521499494d96be85c31317ba", + "metric_config": {"use_llm": false}, + "official_metrics": ["overall", "text_edit", "code_edit", "formula_edit", "table_edit", "table_TEDS"], + "input_contract": { + "body": "official html field encoded as UTF-8 without mutation", + "execution": "inline scripts may run; non-loopback subresources and data access are blocked; local engine receives the same body from an OS-isolated loopback HTTPS fixture with a per-run CA", + "original_url": "retained as evidence metadata only" + }, + "publication_state": "pending", + "require_independent_result_review": false, + "conversion": "Native Markdown from Moli fixed build" +} diff --git a/evaluation/webmainbench-full/publication-target.json b/evaluation/webmainbench-full/publication-target.json new file mode 100644 index 000000000..5ef2febea --- /dev/null +++ b/evaluation/webmainbench-full/publication-target.json @@ -0,0 +1,9 @@ +{ + "schema_version": 1, + "profile_sha256": "8cbceaf9694e88e7c1ddbba3263573dcccf69b1ac89d7e9ea5aa098f2a3bab02", + "moli_fixed_profile_sha256": "db8e7fa408edf3dc2e19d2b0f35645448d0e1efd3ac86926d0bcc9e1a3fb3848", + "host": "https://artifact-site.lexmount.cn", + "site_slug": "zrqPjO672dgg", + "share_url": "https://artifact-site.lexmount.cn/v/Avz3qzo6EEujI0iln1LAzCUF93I47OEC", + "expected_remote_version": "ver_8cbf0496af25497283fdcb750756b422" +} diff --git a/harness/web_fetch/providers/headless.py b/harness/web_fetch/providers/headless.py index d003bd167..863a6bae9 100644 --- a/harness/web_fetch/providers/headless.py +++ b/harness/web_fetch/providers/headless.py @@ -157,6 +157,7 @@ def cli_command( retrieval_plan=None, allow_private_network=False, ca_cert=None, + http_proxy=None, ): ms = str(max(1, math.ceil(timeout * 1000))) if output_mode not in {'html', 'markdown', 'document', 'file'}: @@ -169,16 +170,23 @@ def cli_command( # rejects those responses before extraction can begin. Version 1.1.3 # documents this behavior explicitly; 1.0.6 and 1.1.1 are bound to # release binaries and covered by route probes and end-to-end tests. - return [binary, 'fetch', '--timeout', ms, '--http-timeout', ms, url] + command = [binary, 'fetch'] + if http_proxy: + command += ['--http-proxy', http_proxy] + return command + ['--timeout', ms, '--http-timeout', ms, url] if output_mode == 'markdown': completion = _cli_completion_arguments(engine, retrieval_plan) if engine == 'obscura': # Obscura's documented default is load plus an adaptive, at-most # five-second quiescence wait. Do not replace it with a fixed delay. prefix = [binary, '--allow-private-network'] if allow_private_network else [binary] + if http_proxy: + prefix += ['--proxy', http_proxy] return prefix + ['fetch', '--dump', 'markdown', *(completion or []), '--timeout', str(math.ceil(timeout)), '--quiet', url] common = [binary, 'fetch', '--dump', 'markdown'] + if http_proxy: + common += ['--http-proxy', http_proxy] common += completion or ['--wait-until', wait_until or 'done'] if engine == 'moli': return common + (['--ca-cert', str(ca_cert)] if ca_cert else []) + ['--timeout', ms, '--http-timeout', ms, url] @@ -187,6 +195,8 @@ def cli_command( raise ValueError('Not a native Markdown CLI engine: ' + engine) completion = _cli_completion_arguments(engine, retrieval_plan) common = [binary, 'fetch', '--dump', 'html'] + if http_proxy: + common += ['--http-proxy' if engine in {'moli', 'lightpanda'} else '--proxy', http_proxy] common += completion or ['--wait-until', 'domcontentloaded'] if engine == 'obscura': return common + ['--timeout', str(math.ceil(timeout)), '--quiet', url] @@ -400,6 +410,7 @@ def fetch(self, case: EvalCase, timeout: float) -> ProviderResult: 'retrieval_plan': normalized_plan, 'allow_private_network': bool(self.config.get('allow_private_network')), 'ca_cert': self.config.get('ca_cert'), + 'http_proxy': self.config.get('http_proxy'), 'network_policy': self.config.get('network_policy', 'unrestricted'), 'endpoint': self.config.get('endpoint', 'wss://kitesurf.cloudflare.app/devtools/browser')} diff --git a/harness/web_fetch/report.py b/harness/web_fetch/report.py index b47eed7f1..31c0f06e0 100644 --- a/harness/web_fetch/report.py +++ b/harness/web_fetch/report.py @@ -2724,3 +2724,481 @@ def evidence_links(urls): _wmb_write(out,'attempts/'+attempt_id+'.html',labels[r['provider']]+' · '+case_id,attempt_body,english) from harness.report_languages import write_language_contract write_language_contract(out,include_english=include_english) + + +def webmainbench_full_machine_issue(root: Path, row: dict) -> bool: + """Identify request failures and successful calls without usable page text.""" + if not row["ok"] or not row["output_chars"]: + return True + return bool(ERROR_PAGE.search(contained_path(root, row["artifact_path"]).read_text(encoding="utf-8"))) + + +WEBMAINBENCH_FULL_CAUSE_OWNERS = { + "Moli 解析器崩溃": "Moli 引擎", + "Kitesurf 服务错误": "Kitesurf 服务", + "Kitesurf 请求超时": "Kitesurf 服务或传输,内部阶段未定位", + "Kitesurf 空正文": "Kitesurf 接口,内部原因未知", + "Obscura 空响应": "Obscura", + "Obscura 导航循环": "Obscura", + "冻结页跳转受阻": "共享输入与隔离环境", +} + + +def generate_webmainbench_full(out: Path, dataset: dict, records: list[dict], + summaries: dict, profile: dict) -> None: + """Render the complete frozen WebMainBench cohort inside the shared report module.""" + content = json.loads((out / "report-content.json").read_text(encoding="utf-8")) + failure_analysis = json.loads((out / "failure-analysis.json").read_text(encoding="utf-8")) + reviewed_failures = {(row["provider"], row["case_id"]): row for row in failure_analysis["reviewed_failures"]} + machine_issue_count = sum(webmainbench_full_machine_issue(out, row) for row in records) + adoption = json.loads((out / "provider-adoption.json").read_text(encoding="utf-8")) + cloudflare_matches = {(entry["repo"], match["path"]) for entry in adoption["included"] + for match in entry["matches"] if match["signature"] == "cloudflare-markdown"} + cloudflare_repositories = len({repo for repo, _ in cloudflare_matches}) + def prose(key): + return esc(content[key]) + correction_path = out / 'case-correction.json' + correction = json.loads(correction_path.read_text()) if correction_path.exists() else None + def illustration(name, caption): + if not (out / 'assets' / name).is_file(): + return '' + return ('
' + esc(caption) + '
' + esc(caption) + '(窄屏可横向滚动,点击查看原图。)
') + providers = profile["providers"] + cases = {case["id"]: case for case in dataset["cases"]} + grouped = defaultdict(dict) + for row in records: + grouped[row["case_id"]][row["provider"]] = row + labels = {p: ("Moli 1.1.9(修复后)" if p == "moli-fixed" else profile["versions"][p] if p == "chromium" else p.capitalize() + + (" " + profile["versions"][p] if p != "kitesurf" else "")) for p in providers} + measures = [("overall", "综合分"), ("text_edit", "全文"), ("code_edit", "代码"), + ("formula_edit", "公式"), ("table_edit", "表格内容"), ("table_TEDS", "表格结构")] + + def percent(value): + return "—" if value is None else f"{value * 100:.1f}%" + + def score(row, metric): + return row.get("metrics", {}).get(metric, {}).get("score") + + def classify(row): + if not row["ok"]: + return "请求失败" + if not row["output_chars"]: + return "空输出" + if ERROR_PAGE.search(contained_path(out, row["artifact_path"]).read_text(encoding="utf-8")): + return "错误页" + if score(row, "text_edit") is not None and score(row, "text_edit") < .1: + return "低全文相似度" + return "未触发诊断" + + categories = {row["attempt_id"]: classify(row) for row in records} + counts = Counter(categories.values()) + target_body = { + row["attempt_id"]: categories[row["attempt_id"]] not in {"请求失败", "空输出", "错误页"} + for row in records + } + + def target_body_count(provider): + return sum(target_body[row["attempt_id"]] for row in records if row["provider"] == provider) + css = """*{box-sizing:border-box}body{margin:0;background:#f4f7fb;color:#172637;font:16px/1.55 system-ui,sans-serif}body.dark{background:#101923;color:#edf3fa}a{color:#1859a5}body.dark a{color:#92c7ff}.wrap{width:min(1200px,calc(100% - 32px));margin:auto}header{background:#142d4b;color:white}header .wrap{display:flex;align-items:center;gap:18px;flex-wrap:wrap;padding:15px 0}header a{color:white;text-decoration:none}header strong{margin-right:auto}nav{display:flex;gap:14px;flex-wrap:wrap}button,input,select{font:inherit;border:1px solid #9aacc0;border-radius:7px;padding:7px 10px;background:white;color:#172637}body.dark button,body.dark input,body.dark select{background:#25384b;color:white;border-color:#61768b}h1{font-size:clamp(30px,4vw,48px);line-height:1.18;margin:0 0 15px}h2{font-size:26px;margin:0 0 14px}.hero{padding:48px 0 22px}.hero p{font-size:18px;max-width:900px;color:#506377}body.dark .hero p,body.dark .note{color:#bfd0e1}section{margin:24px 0 38px}figure{margin:24px 0}.panel{background:white;border:1px solid #d8e1eb;border-radius:12px;padding:20px;margin:14px 0}body.dark .panel{background:#1c2b3b;border-color:#38506a}.scroll{overflow:auto;max-height:66vh;border:1px solid #d8e1eb;border-radius:10px;background:white}body.dark .scroll{background:#1c2b3b;border-color:#38506a}table{border-collapse:collapse;width:100%;min-width:820px}th,td{padding:9px 12px;text-align:left;border-bottom:1px solid #dce5ee;vertical-align:top}th{position:sticky;top:0;background:#e9f0f8;color:#152a40;z-index:1}body.dark th{background:#29435d;color:white}tbody tr:hover{background:#edf4fc}body.dark tbody tr:hover{background:#28445f}td small{display:block;color:#597088}body.dark td small{color:#b8cadb}.number{font-variant-numeric:tabular-nums;white-space:nowrap}.winner{color:#116b3c;font-weight:750}body.dark .winner{color:#82e1a7}.filters{display:flex;gap:12px;flex-wrap:wrap;margin:14px 0}.filters label{display:grid;gap:4px;font-size:13px;font-weight:700}.note{color:#52677b;font-size:14px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#0e2032;color:#f1f6fb;padding:17px;border-radius:8px;max-height:65vh;overflow:auto;font:13px/1.5 ui-monospace,monospace}.compare-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}.compare-grid .panel{min-width:0;margin:0}.compare-grid pre{max-height:70vh}details{border:1px solid #dce5ee;border-radius:8px;padding:10px 14px;margin:10px 0}body.dark details{border-color:#38506a}summary{cursor:pointer;font-weight:700}.actions{display:flex;gap:14px;flex-wrap:wrap}.source{overflow-wrap:anywhere}footer{padding:32px 0;color:#52677b}@media(max-width:800px){.compare-grid{grid-template-columns:1fr}}@media(max-width:650px){.panel{padding:14px}header nav{width:100%}th,td{padding:7px 9px}}""" + js = """function theme(){document.body.classList.toggle('dark');try{localStorage.setItem('wmb545-theme',document.body.classList.contains('dark')?'dark':'light')}catch(e){}}try{if(localStorage.getItem('wmb545-theme')==='dark')document.body.classList.add('dark')}catch(e){}function filter(){let q=(document.getElementById('q')?.value||'').toLowerCase(),p=document.getElementById('provider')?.value||'',k=document.getElementById('kind')?.value||'issue',cause=document.getElementById('cause')?.value||'',n=0;document.querySelectorAll('tr[data-result]').forEach(r=>{let show=(!q||r.dataset.search.includes(q))&&(!p||r.dataset.provider===p)&&(!cause||r.dataset.cause===cause)&&(k==='all'||(k==='issue'?r.dataset.kind!=='未触发诊断':r.dataset.kind===k));r.hidden=!show;if(show)n++});let c=document.getElementById('count');if(c)c.textContent=n;let e=document.getElementById('empty');if(e)e.hidden=n!==0}document.addEventListener('DOMContentLoaded',()=>{for(let id of ['q','provider','kind','cause'])document.getElementById(id)?.addEventListener('input',filter);let params=new URLSearchParams(location.search);for(let id of ['provider','kind','cause']){let select=document.getElementById(id);if(select&¶ms.has(id))select.value=params.get(id)}filter();let node=document.getElementById('evidence-content');if(node){let path=params.get('path')||'';if(!/^(artifacts|references|sources)\\/[A-Za-z0-9_.\\/-]+$/.test(path)||path.includes('..')){node.textContent='无效证据路径';return}document.getElementById('evidence-path').textContent=path;document.getElementById('evidence-raw').href=path;loadEvidence(path,node)}});""" + js += """ +async function loadJson(path){let response=await fetch(path);if(!response.ok)throw Error('HTTP '+response.status);return response.json()} +async function loadEvidence(path,node){try{let index=await loadJson('artifact-shard-index.json'),shardPath=index[path];if(!shardPath)throw Error('证据索引缺失');let shard=await loadJson(shardPath),hash=shard.artifacts[path],value=shard.blobs[hash];if(typeof value!=='string')throw Error('证据内容缺失');node.textContent=value;let raw=document.getElementById('evidence-raw');raw.href=URL.createObjectURL(new Blob([value],{type:'text/plain;charset=utf-8'}));raw.download=path.split('/').pop()}catch(error){node.textContent='无法读取证据:'+error.message}} +async function renderResult(){let params=new URLSearchParams(location.search),provider=params.get('provider')||'',id=params.get('id')||'';let title=document.getElementById('result-title');if(!/^[a-z0-9-]+$/.test(provider)||!/^[A-Za-z0-9-]+$/.test(id)){title.textContent='无效结果地址';return}try{let index=await loadJson('result-shard-index.json'),key=provider+'|'+id,shardPath=index[key];if(!shardPath)throw Error('结果索引缺失');let shard=await loadJson(shardPath),row=shard.results[key];if(!row)throw Error('结果内容缺失');let names={moli:'Moli 1.1.9', 'moli-fixed':'Moli 1.1.9(修复后)',lightpanda:'Lightpanda',obscura:'Obscura',chromium:'Chrome',kitesurf:'Kitesurf'},name=names[provider]||provider;title.textContent=name+' · '+id;document.title=title.textContent+' · WebMainBench 545';document.getElementById('result-source').textContent=row.original_url;let failure=['请求失败','空输出','错误页'].includes(row.diagnosis),detail=document.querySelector('[data-report-detail]');detail.dataset.reportDetail=failure?'failed':'success';document.getElementById('result-summary').textContent='状态:'+(row.ok?'返回成功':'请求失败')+';诊断:'+row.diagnosis+';最终调用 '+row.elapsed_ms.toFixed(0)+' ms;物理调用 '+row.physical_attempt_count+' 次,含重试总耗时 '+row.total_elapsed_ms.toFixed(0)+' ms。';let correction=document.getElementById('result-correction');correction.hidden=!row.supersedes_attempt_id;let section=document.getElementById('result-failure');section.hidden=!failure;if(failure){document.getElementById('result-stage').textContent='阶段:'+(row.error_type||row.diagnosis);document.getElementById('result-error').textContent='程序错误:'+(row.error_message||'无');document.getElementById('result-log').textContent=row.provider_metadata?.stderr||row.provider_metadata?.browser_stderr||'无日志';document.getElementById('result-cause').textContent='复核原因:'+(row.review?.cause||'未记录');document.getElementById('result-treatment').textContent='处理:'+(row.review?.treatment||'未记录').replace(/[。;]+$/,'')+';状态:'+(row.review?.status||'未记录')}let labels=[['text_edit','全文'],['code_edit','代码'],['formula_edit','公式'],['table_edit','表格内容'],['table_TEDS','表格结构']],body=document.getElementById('result-metrics');for(let [metric,label] of labels){let tr=document.createElement('tr'),entry=row.metrics?.[metric]||{};for(let value of [label,(!entry.success||entry.score==null)?'—':(entry.score*100).toFixed(1)+'%',entry.success?'参与计分':'不参与计分']){let td=document.createElement('td');td.textContent=value;tr.append(td)}body.append(tr)}document.getElementById('result-case').href='cases/index.html?id='+id;document.getElementById('result-output').href='viewer/index.html?path='+encodeURIComponent(row.artifact_path);document.getElementById('result-reference').href='viewer/index.html?path=references/'+id+'.md';document.getElementById('result-source-link').href='viewer/index.html?path=sources/'+id+'.html.txt';let physical=shard.physical[id]||[],list=document.getElementById('result-physical-list');for(let [number,attempt] of physical.entries()){let li=document.createElement('li'),a=document.createElement('a');a.href='viewer/index.html?path='+encodeURIComponent(attempt.physical_artifact_path);a.textContent='第 '+(number+1)+' 次调用 · '+attempt.elapsed_ms.toFixed(0)+' ms';li.append(a);list.append(li)}document.getElementById('result-physical').hidden=!physical.length}catch(error){title.textContent='无法载入结果:'+error.message}} +async function renderCase(){ + let id=new URLSearchParams(location.search).get('id')||'',slot=document.getElementById('case-id'); + if(!/^[A-Za-z0-9-]+$/.test(id)){slot.textContent='无效任务地址';return} + try{ + let dataset=await loadJson('dataset.json'),item=dataset.cases.find(x=>x.id===id); + if(!item)throw Error('任务不存在'); + slot.textContent=id;document.getElementById('case-url').textContent=item.url; + document.title='同页比较 '+id+' · WebMainBench 545'; + document.getElementById('case-source').href='viewer/index.html?path=sources/'+id+'.html.txt'; + document.getElementById('case-reference').href='viewer/index.html?path=references/'+id+'.md'; + let resultIndex=await loadJson('result-shard-index.json'),artifactIndex=await loadJson('artifact-shard-index.json'); + let names={moli:'Moli 1.1.9','moli-fixed':'Moli 1.1.9(修复后)',lightpanda:'Lightpanda',obscura:'Obscura',chromium:'Chrome',kitesurf:'Kitesurf'}; + let metrics=['overall','text_edit','code_edit','formula_edit','table_edit','table_TEDS']; + let providers=dataset.coverage_plan.static_html,referencePath='references/'+id+'.md'; + let paths=[...new Set([...providers.map(p=>resultIndex[p+'|'+id]),artifactIndex[referencePath]])]; + if(paths.some(x=>!x))throw Error('同页证据不完整'); + let loaded=await Promise.all(paths.map(loadJson)),shards=Object.fromEntries(paths.map((p,i)=>[p,loaded[i]])); + let referenceShard=shards[artifactIndex[referencePath]],reference=referenceShard.blobs[referenceShard.artifacts[referencePath]]; + if(typeof reference!=='string')throw Error('人工答案缺失'); + let grid=document.getElementById('case-comparison'),referencePanel=document.createElement('article'); + referencePanel.className='panel';let referenceTitle=document.createElement('h2');referenceTitle.textContent='人工主内容答案'; + let referenceNote=document.createElement('p');referenceNote.className='note';referenceNote.textContent='人工答案是评测参考,判断正文与格式仍需结合冻结网页和各方法输出。'; + let referenceBody=document.createElement('pre');referenceBody.textContent=reference; + referencePanel.append(referenceTitle,referenceNote,referenceBody);grid.append(referencePanel); + let body=document.getElementById('case-results'); + for(let provider of providers){ + let shard=shards[resultIndex[provider+'|'+id]],row=shard.results[provider+'|'+id]; + if(!row)throw Error('同页方法缺失'); + let output=shard.blobs[shard.artifacts[row.artifact_path]]; + if(typeof output!=='string')throw Error('方法输出缺失:'+provider); + let tr=document.createElement('tr'),td=document.createElement('td'),link=document.createElement('a'); + link.href='results/index.html?provider='+provider+'&id='+id;link.textContent=names[provider]||provider; + td.append(link);tr.append(td);td=document.createElement('td');td.textContent=row.diagnosis;tr.append(td); + for(let metric of metrics){td=document.createElement('td');let entry=row.metrics?.[metric];td.textContent=(!entry?.success||entry.score==null)?'—':(entry.score*100).toFixed(1)+'%';tr.append(td)} + body.append(tr); + let panel=document.createElement('article');panel.className='panel result-output'; + let title=document.createElement('h2');title.textContent=names[provider]||provider; + let note=document.createElement('p');note.className='note';note.textContent='诊断:'+row.diagnosis+';正文 '+row.output_chars+' 字。'; + let detail=document.createElement('a');detail.href=link.href;detail.textContent='查看评分、错误与调用记录'; + let raw=document.createElement('pre');raw.textContent=output||'(空输出)'; + panel.append(title,note,detail,raw);grid.append(panel); + } + }catch(error){slot.textContent='无法载入同页结果:'+error.message} +} +document.addEventListener('DOMContentLoaded',()=>{if(document.getElementById('result-title'))renderResult();if(document.getElementById('case-id'))renderCase()}); +""" + js += """ +async function openDiagnosis(button){let tr=button.closest('tr[data-result]'),dialog=document.getElementById('diagnosis-dialog'),provider=tr?.dataset.provider,id=tr?.dataset.caseId;if(!dialog||!provider||!id)return;let put=(name,value)=>{document.getElementById('diagnosis-'+name).textContent=value};put('title','正在读取诊断…');put('error','');put('log','');put('cause','');put('treatment','');dialog.showModal();try{let index=await loadJson('result-shard-index.json'),key=provider+'|'+id,shard=await loadJson(index[key]),row=shard.results[key];if(!row)throw Error('结果证据缺失');put('title',id+' · '+row.diagnosis);put('stage',row.error_type?'失败阶段:'+row.error_type:'诊断类别:'+row.diagnosis);put('error','程序错误:'+(row.error_message||'无'));put('log',row.provider_metadata?.stderr||row.provider_metadata?.browser_stderr||row.error_message||'无日志');put('cause','复核原因:'+(row.review?.cause||'未独立复核'));put('treatment','处理:'+(row.review?.treatment||'未记录')+';状态:'+(row.review?.status||'未记录'));document.getElementById('diagnosis-result').href='results/index.html?provider='+provider+'&id='+id;document.getElementById('diagnosis-output').href='viewer/index.html?path='+encodeURIComponent(row.artifact_path);document.getElementById('diagnosis-case').href='cases/index.html?id='+id}catch(error){put('title','诊断读取失败');put('error',error.message)}} +document.addEventListener('DOMContentLoaded',()=>{let dialog=document.getElementById('diagnosis-dialog');if(!dialog)return;document.getElementById('diagnosis-close').addEventListener('click',()=>dialog.close());document.addEventListener('click',event=>{let button=event.target.closest('button[data-diagnose]');if(button)openDiagnosis(button)})}); +""" + css += "code,#result-error{overflow-wrap:anywhere;word-break:break-word}dialog{width:min(760px,calc(100% - 24px));max-height:85vh;overflow:auto;border:1px solid #9aacc0;border-radius:12px;background:white;color:#172637;padding:22px}dialog::backdrop{background:#0b172bc9}body.dark dialog{background:#1c2b3b;color:#edf3fa;border-color:#61768b}dialog .actions{margin-top:16px}dialog pre{max-height:28vh}td button[data-diagnose]{font-size:14px;padding:2px 6px}" + (out / "assets").mkdir(exist_ok=True) + (out / "assets/style.css").write_text(css, encoding="utf-8") + (out / "assets/report.js").write_text(js, encoding="utf-8") + + def anchor(path, label): + return '' + esc(label) + '' + + def table(headers, rows): + return '
' + ''.join("" for x in headers) + '' + ''.join(rows) + '
" + esc(x) + "
' + + navigation = [("总览", "index.html")] + navigation += [("数据集", "dataset/index.html"), + ("结果分析", "analysis/index.html"), ("解读", "guide/index.html"), + ("方法", "methodology/index.html"), ("复现", "reproduce/index.html"), + ("更新记录", "history/index.html")] + + def page(route, title, body): + prefix = "../" * route.count("/") + links = ''.join(anchor(path, label) for label, path in navigation) + markup = ('' + esc(title) + ' · WebMainBench 545
' + + anchor("index.html", "WebMainBench · 545") + '' + anchor("en/index.html", "English") + + '
' + body + + '
') + target = out / route + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(markup, encoding="utf-8") + + def metric_table(): + rows = [] + for provider in providers: + summary = summaries[provider] + scores = summary["official_aggregate"]["scores"] + denominators = summary["official_aggregate"]["denominators"] + cells = ["" + anchor("analysis/index.html?provider=" + provider, labels[provider]) + ""] + for metric, _ in measures: + value = scores[metric] + all_values = [summaries[other]["official_aggregate"]["scores"][metric] for other in providers] + winner = "winner comparison-winner" if value == max(all_values) and all_values.count(value) == 1 else "" + detail = "" + str(denominators[metric]) + " 页计分" if metric != "overall" else "" + digits = 2 if metric == 'overall' else 3 if metric == 'code_edit' else 1 + cells.append('' + f'{value * 100:.{digits}f}%' + "" + detail + "") + cells.append('' + + str(target_body_count(provider)) + "/545") + rows.append("" + "".join(cells) + "") + return table(["方法"] + [label for _, label in measures] + ["目标正文"], rows) + + def latency_table(): + rows = [] + by_provider = {provider: [row for row in records if row["provider"] == provider] for provider in providers} + successful_by_provider = { + provider: [row["elapsed_ms"] for row in by_provider[provider] if target_body[row["attempt_id"]]] + for provider in providers + } + values_by_provider = {provider: ([statistics.mean(values), percentile(values, .5), percentile(values, .95)] if values else [None] * 3) + + [sum(row["total_elapsed_ms"] for row in by_provider[provider])] + for provider, values in successful_by_provider.items()} + for provider in providers: + numbers = [] + for index, value in enumerate(values_by_provider[provider]): + eligible = [values_by_provider[other][index] for other in providers if other != "moli-fixed" and values_by_provider[other][index] is not None] + unique = provider != "moli-fixed" and value is not None and value == min(eligible) and eligible.count(value) == 1 + marker = '' if unique else '' + end = '' if unique else '' + numbers.append('' + marker + + (f"{value:,.0f} ms" if value is not None else "—") + end + '') + rows.append("" + esc(labels[provider]) + "" + str(len(successful_by_provider[provider])) + "/545" + ''.join(numbers) + "") + return table(["方法", "目标正文页", "均值", "P50", "P95", "含重试总耗时"], rows) + + def all_call_latency_table(): + distributions = {provider: [row["elapsed_ms"] for row in records if row["provider"] == provider] for provider in providers} + values = {provider: [statistics.mean(items), percentile(items, .5), percentile(items, .95)] + for provider, items in distributions.items()} + rows = [] + for provider in providers: + cells = [] + for index, number in enumerate(values[provider]): + column = [values[other][index] for other in providers if other != "moli-fixed"] + unique = provider != "moli-fixed" and number == min(column) and column.count(number) == 1 + marker = '' if unique else '' + cells.append('' + marker + f'{number:,.0f} ms' + ('' if unique else '') + '') + rows.append('' + esc(labels[provider]) + '545/545' + ''.join(cells) + '') + return table(['方法', '全部逻辑调用', '均值', 'P50', 'P95'], rows) + + home = ('

' + prose('home_title') + '

' + prose('home_lead') + '

' + + '

核心结果

' + metric_table() + '

' + prose('score_note') + '

' + + '

全部页面的调用耗时

' + all_call_latency_table() + '

' + prose('all_latency_note') + '

' + + '

目标正文页面的调用耗时与重试成本

' + latency_table() + '

' + prose('latency_note') + '

' + + '

问题分布

' + table(["自动诊断", "结果数"], ['' + anchor("analysis/index.html?kind=" + kind, kind) + '' + str(count) + '' for kind, count in sorted(counts.items())]) + + '

' + prose('diagnostic_note') + '

') + page("index.html", "完整结果", home) + dataset_rows = [] + for case in dataset["cases"]: + case_id = case["id"] + meta = case["meta"] + dataset_rows.append("" + anchor("cases/index.html?id=" + case_id, case_id) + "" + + esc(meta.get("language") or "—") + "" + esc(meta.get("level") or "—") + + '' + anchor(case["url"], case["url"]) + "" + + anchor("viewer/index.html?path=sources/" + case_id + ".html.txt", "冻结网页") + " · " + + anchor("viewer/index.html?path=references/" + case_id + ".md", "人工答案") + "") + page("dataset/index.html", "数据集", '

冻结网页与人工答案

' + prose('dataset_lead') + '

' + + table(["任务 ID", "语言", "难度", "来源", "证据"], dataset_rows)) + options = ["issue", "all", "请求失败", "空输出", "错误页", "低全文相似度", "未触发诊断"] + cause_owners = WEBMAINBENCH_FULL_CAUSE_OWNERS + cause_order = tuple(cause_owners) + filters = ('
") + result_rows = [] + for row in records: + case_id, provider = row["case_id"], row["provider"] + kind = categories[row["attempt_id"]] + review = reviewed_failures.get((provider, case_id)) + result_rows.append('' + + anchor("cases/index.html?id=" + case_id, case_id) + "" + + anchor("results/index.html?provider=" + provider + "&id=" + case_id, labels[provider]) + "" + + ('' if kind != '未触发诊断' else esc(kind)) + "" + + percent(score(row, "text_edit")) + "" + f'{row["elapsed_ms"]:,.0f} ms' + + "" + anchor("cases/index.html?id=" + case_id, "同页") + "") + page("analysis/index.html", "结果分析", '

逐项结果与问题定位

' + prose('analysis_lead') + '

' + + filters + '

当前显示 条。

' + + table(["任务", "方法", "诊断", "全文分", "最终调用", "同页"], result_rows) + + '

原始运行日志

完整结果与调用记录原始输出同页六方法对照

') + page("cases/index.html", "同页比较", + '

人工答案与六种方法的完整输出

' + '

冻结网页人工答案' + anchor("dataset/index.html", "返回数据集") + '

' + '
' + ''.join('' for _, label in measures) + + '
方法诊断' + esc(label) + '
' + '

同页内容对照

') + page("results/index.html", "逐项结果", + '

正在载入结果…

' + '

结果摘要

' + '' + '' + '

五项质量指标

指标分数状态
' + '' + '

返回同页比较完整输出人工答案' + '冻结网页

') + shards = defaultdict(lambda: {"results": {}, "artifacts": {}, "blobs": {}, "physical": {}}) + result_index = {} + artifact_index = {} + def shard_name(provider, case_id): + bucket = int(hashlib.sha256(case_id.encode()).hexdigest()[:2], 16) % 8 + return f"shards/{provider}-{bucket}.json" + def bound_artifact(path, expected, shard_path): + source = contained_path(out, path) + raw = source.read_bytes() + if hashlib.sha256(raw).hexdigest() != expected: + raise ValueError("Public artifact changed: " + path) + shards[shard_path]["artifacts"][path] = expected + shards[shard_path]["blobs"][expected] = raw.decode("utf-8") + artifact_index[path] = shard_path + for row in records: + key = row["provider"] + "|" + row["case_id"] + target = shard_name(row["provider"], row["case_id"]) + shards[target]["results"][key] = {**row, "diagnosis": categories[row["attempt_id"]], + "review": reviewed_failures.get((row["provider"], row["case_id"]))} + result_index[key] = target + bound_artifact(row["artifact_path"], row["output_sha256"], target) + physical_path = out / "physical-attempts.jsonl" + physical = [json.loads(line) for line in physical_path.read_text(encoding="utf-8").splitlines() if line.strip()] if physical_path.is_file() else [] + for attempt in physical: + case_id = attempt["track_id"] + target = shard_name(attempt["provider"], case_id) + shards[target]["physical"].setdefault(case_id, []).append(attempt) + bound_artifact(attempt["physical_artifact_path"], attempt["output_sha256"], target) + for historical_name, path_key in [('superseded-case-results.jsonl', 'artifact_path'), + ('superseded-case-calls.jsonl', 'physical_artifact_path')]: + historical_path = out / historical_name + if historical_path.is_file(): + for attempt in (json.loads(line) for line in historical_path.read_text().splitlines()): + case_id = attempt.get('case_id', attempt.get('track_id')) + target = shard_name(attempt['provider'], case_id) + bound_artifact(attempt[path_key], attempt['output_sha256'], target) + for case in dataset["cases"]: + case_id = case["id"] + target = shard_name("source", case_id) + bound_artifact(f"sources/{case_id}.html.txt", case["input_html_sha256"], target) + bound_artifact(f"references/{case_id}.md", case["reference_sha256"], target) + for relative, data in shards.items(): + target = out / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + (out / "result-shard-index.json").write_text(json.dumps(result_index, separators=(",", ":")), encoding="utf-8") + (out / "artifact-shard-index.json").write_text(json.dumps(artifact_index, separators=(",", ":")), encoding="utf-8") + shutil.rmtree(out / "artifacts") + shutil.rmtree(out / "sources") + shutil.rmtree(out / "references") + interface_rows = ['' + esc(labels[provider]) + '' + esc(content['interfaces'][provider]) + '' + for provider in providers] + retry_count = sum(row["physical_attempt_count"] - 1 for row in records) + recovered_count = sum(row["ok"] and row["physical_attempt_count"] == 2 for row in records) + final_request_failures = sum(not row["ok"] for row in records) + page("methodology/index.html", "评测方法", + '

测量与评分方法

' + prose('method_lead') + '

' + + '

输入

' + prose('method_input') + '

数据 SHA-256:' + + esc(profile["source_dataset_sha256"]) + '。评分器提交:' + esc(profile["official_scorer_commit"]) + + '。

失败重试

本轮共 ' + str(len(records) + retry_count) + + ' 次物理调用,包含 ' + str(retry_count) + ' 次失败后的重试;其中 ' + str(recovered_count) + + ' 条重试后返回成功,' + str(final_request_failures) + + ' 条最终请求失败均调用两次。返回成功但正文为空或为错误页的结果不触发请求失败重试。

' + + '

评分

' + prose('method_scoring') + '

' + prose('method_correction') + '

' + + anchor("teds-correction.json", "修正收据") + " · " + anchor("teds-correction-review.json", "修正复核") + "
" + + '

调用接口

' + table(['方法', '本轮调用方式'], interface_rows) + '
' + + '

' + prose('kitesurf_version_note') + '

' + + '

公开采用证据

' + prose('adoption_lead') + ' 截至 ' + esc(adoption['cutoff'][:10]) + + ',Cloudflare Markdown 接口在筛选范围内见于 ' + str(cloudflare_repositories) + ' 个仓库、' + str(len(cloudflare_matches)) + + ' 个文件。

' + prose('adoption_limit') + '

' + anchor('provider-adoption.json', '查看检索样本与纳入规则') + '
') + example_id = dataset["cases"][0]["id"] + example_row = grouped[example_id][providers[0]] + example_links = " · ".join([ + anchor("viewer/index.html?path=sources/" + example_id + ".html.txt", "冻结 HTML"), + anchor("viewer/index.html?path=references/" + example_id + ".md", "人工答案"), + anchor("viewer/index.html?path=" + example_row["artifact_path"], labels[providers[0]] + " 输出"), + anchor("cases/index.html?id=" + example_id, "六种方法同页比较"), + ]) + overall_scores = {provider: summaries[provider]["official_aggregate"]["scores"]["overall"] + for provider in providers} + highest = max(overall_scores.values()) + leaders = [provider for provider in providers if overall_scores[provider] == highest] + outcome = ("本轮 545 页中," + labels[leaders[0]] + " 的综合分最高,为 " + f'{highest * 100:.2f}%' + "。" + if len(leaders) == 1 else + "本轮 545 页的综合分最高值为 " + percent(highest) + ",由 " + + "、".join(labels[provider] for provider in leaders) + " 并列取得。") + old_moli, fixed_moli = summaries['moli'], summaries['moli-fixed'] + changed_moli = [case_id for case_id in cases + if target_body[grouped[case_id]['moli']['attempt_id']] != + target_body[grouped[case_id]['moli-fixed']['attempt_id']]] + changed_moli_rows = ["" + anchor("cases/index.html?id=" + case_id, case_id) + + "" + ("有正文" if target_body[grouped[case_id]['moli']['attempt_id']] else "无正文") + + "" + ("有正文" if target_body[grouped[case_id]['moli-fixed']['attempt_id']] else "无正文") + + "" + anchor("results/index.html?provider=moli-fixed&id=" + case_id, "修复版输出与错误") + + "" for case_id in changed_moli] + fixed_section = ('

Moli 修复前后

原版生成目标正文 ' + + str(target_body_count('moli')) + '/545,修复版 ' + + str(target_body_count('moli-fixed')) + '/545。经同一表格结构评分修正,综合分分别为 ' + + f"{old_moli['official_aggregate']['scores']['overall'] * 100:.2f}%" + ' 和 ' + + f"{fixed_moli['official_aggregate']['scores']['overall'] * 100:.2f}%" + '。' + + prose('fixed_quality') + '

' + prose('fixed_mechanism') + '

' + + table(['任务', '原版正文', '修复版正文', '逐项证据'], changed_moli_rows) + + '

修复版另一次全量运行;两版的耗时不是同一时间窗交叉测量。' + + anchor('six-method-binding.json', '两轮认证证据') + ' · ' + + anchor('https://github.com/lexmount/moli/pull/785', '修复代码与 CI') + '

') + correction_section = '' + if correction: + corrected_id = correction['preflight']['case_id'] + correction_section = ('

狼人杀规则页:测试地址导致的假失败已纠正

' + '

页面脚本会检查当前域名和路径。旧环境把地址替换为 localhost,触发跳回原站;隔离规则随后阻断访问。' + '修正后的环境保留原网址,由本地代理提供同一份冻结 HTML,继续阻断外部资源。六种方法使用原受测二进制完成同页认证补测,均返回正文。

' + + table(['方法', '补测正文字符数', '补测全文分', '证据'], [ + '' + esc(labels[p]) + '' + str(grouped[corrected_id][p]['output_chars']) + + '' + percent(score(grouped[corrected_id][p], 'text_edit')) + '' + + anchor('results/index.html?provider=' + p + '&id=' + corrected_id, '完整输出') + '' for p in providers]) + + '

这六条结果统一替换旧环境下的同页结果,其他 544 页不变。修复版 Moli 已生成正文 545/545;' + '历史 544/545 对应修正前的测试环境。旧调用额外耗时 ' + f"{correction['superseded_execution_ms']:,.0f}" + + ' ms 单独保留,不混入补测最终调用延迟。

' + anchor('case-correction.json', '认证与修订依据') + ' · ' + + anchor('superseded-case-results.jsonl', '被替换的六条历史结果') + '

') + method_rows = [] + diagnostic_rows = [] + diagnostic_kinds = ("请求失败", "空输出", "错误页", "低全文相似度") + for provider in providers: + selected = [row for row in records if row["provider"] == provider] + issues = sum(categories[row["attempt_id"]] != "未触发诊断" for row in selected) + method_rows.append("" + anchor("analysis/index.html?provider=" + provider, labels[provider]) + + "" + str(target_body_count(provider)) + + "/545" + str(issues) + "" + + prose("guide_method_" + provider) + "") + diagnostic_rows.append("" + esc(labels[provider]) + "" + "".join( + '' + + anchor("analysis/index.html?provider=" + provider + "&kind=" + kind, + str(sum(categories[row["attempt_id"]] == kind for row in selected))) + "" + for kind in diagnostic_kinds) + "") + cause_counts = Counter((row["category_label"], row["provider"]) + for row in failure_analysis["reviewed_failures"]) + unknown_causes = {cause for cause, _ in cause_counts} - set(cause_owners) + if unknown_causes: + raise ValueError("Reviewed failure cause lacks an owner: " + ", ".join(sorted(unknown_causes))) + cause_rows = [] + for cause in cause_order: + cells = [] + for provider in providers: + count = cause_counts[cause, provider] + label = str(count) if count else "—" + target = "analysis/index.html?provider=" + provider + "&cause=" + cause + cells.append('' + + (anchor(target, label) if count else label) + '') + cause_rows.append('' + esc(cause) + '' + ''.join(cells) + + '' + esc(cause_owners[cause]) + '') + page("guide/index.html", "结果解读", + '

结果解读

' + esc(outcome) + " " + prose('guide_lead') + '

质量与样本

' + + metric_table() + '

' + prose('guide_scope') + '

核对完整输出

' + prose('guide_outputs') + '

' + + anchor("analysis/index.html?kind=低全文相似度", "查看低全文分案例") + + '

可检查的一页

' + prose('guide_example') + '

' + example_links + '

' + + '
' + illustration('overview.png', '545 页六方法结果与错误分布;分数与计分范围以表格为准。') + fixed_section + correction_section + + illustration('error-mechanisms.png', '浏览器实现、托管服务与共享测试环境的错误机制;点击查看原图。') + + '

各方法的主要问题

' + + table(["方法", "目标正文", "自动诊断数", "本轮观察"], method_rows) + + '

' + prose('guide_method_scope') + '

' + + table(["方法", *diagnostic_kinds], diagnostic_rows) + '

错误类别

' + + table(["类别", "结果数"], ["" + esc(kind) + "" + str(count) + "" for kind, count in sorted(counts.items())]) + + '

' + prose('guide_diagnosis') + '

已复核问题:错误类型 × 方法

' + + table(["错误类型", *[labels[provider] for provider in providers], "归属或定位"], cause_rows) + + '

' + str(len(reviewed_failures)) + '/' + str(machine_issue_count) + ' ' + prose('review_scope_failure') + + ';' + str(len(records) - len(reviewed_failures)) + ' ' + prose('review_scope_pass') + '

' + + anchor('failure-analysis.json', '查看逐条失败原因与处理') + + '

结论

' + prose('guide_conclusion') + '

') + evidence_links = ["current-report.json", "cohort-certificate.json", "scoring-certificate.json", + "moli-fixed-report.json", "fixed-cohort-certificate.json", "fixed-scoring-certificate.json", "six-method-binding.json", + "execution.json", "dataset.json", + "results.jsonl", "original-results.jsonl", "physical-attempts.jsonl", + "score-summaries.json", "teds-correction-review.json", "report-content.json", "failure-analysis.json"] + if correction: + evidence_links += ['case-correction.json', 'superseded-case-results.jsonl', 'superseded-case-calls.jsonl'] + page("reproduce/index.html", "复现依据", + '

复现依据

' + prose('reproduce_lead') + '

' + + " · ".join(anchor(name, name) for name in evidence_links) + "

") + releases = json.loads((Path(__file__).resolve().parents[2] / 'research/report-changes.json').read_text(encoding='utf-8'))['releases'] + webmainbench_releases = [item for item in releases if 'webmainbench_frozen_html_to_markdown' in item.get('comparison_kinds', [])] + history_rows = ['' + esc(item['date']) + + '' + esc(item['title']) + '' + esc(item['summary']) + '' + for item in webmainbench_releases] + page('history/index.html', '更新记录', + '

报告更新记录

按发布记录说明读者可见的结果与内容变化。

' + + table(['日期', '更新', '内容'], history_rows)) + page("viewer/index.html", "证据查看器", + '

证据查看器

按需载入原始证据。

原始文件

正在载入…
') + page("en/index.html", "英文版未生成", + '

英文版未生成

本轮报告仅生成中文版。

' + + anchor("index.html", "返回中文报告")) + (out / "report-manifest.json").write_text(json.dumps({ + "schema_version": 1, "report": "webmainbench-545-full", "languages": ["zh"], + "case_count": 545, "provider_count": len(providers), "result_count": len(records), + }, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/harness/web_fetch/report_build.py b/harness/web_fetch/report_build.py index 251fc65c3..437808cf9 100644 --- a/harness/web_fetch/report_build.py +++ b/harness/web_fetch/report_build.py @@ -5,7 +5,7 @@ import tempfile from pathlib import Path from harness.reporting.assets import copy_bound_assets -from .report import generate, generate_public_benchmark_research, generate_webmainbench, generate_wcxb, succeeded, measured, failure_category, TRACKS +from .report import generate, generate_public_benchmark_research, generate_webmainbench, generate_webmainbench_full, webmainbench_full_machine_issue, generate_wcxb, succeeded, measured, failure_category, TRACKS from .report_changes import webmainbench_history from .failure_diagnosis import diagnose from .safety import contained_path, safe_identifier, validate_report_ids @@ -559,6 +559,143 @@ def build_webmainbench_report(source: Path, output: Path, *, include_english: bo return output / 'index.html' +def build_webmainbench_full_report(source: Path, output: Path) -> Path: + """Build the separately bound complete 545-case report from certified evidence.""" + from scripts.run_certified_webmainbench_full import digest, read_jsonl + from .webmainbench_diagnostics import validate_teds_revision + if source.is_symlink() or any(path.is_symlink() for path in source.rglob('*')): + raise ValueError('Symlinked report evidence is forbidden') + profile_path = Path(__file__).resolve().parents[2] / 'evaluation/webmainbench-full/current-report.json' + profile = json.loads(profile_path.read_text(encoding='utf-8')) + certificate = json.loads((source / 'cohort-certificate.json').read_text(encoding='utf-8')) + scoring_certificate = json.loads((source / 'scoring-certificate.json').read_text(encoding='utf-8')) + if (certificate.get('kind') != 'certified_webmainbench_545_full_v1' + or certificate.get('profile_sha256') != digest(profile_path) + or certificate.get('preflight') != certificate.get('postflight') + or json.loads((source / 'current-report.json').read_text(encoding='utf-8')) != profile): + raise ValueError('Full-cohort certificate or profile mismatch') + if (scoring_certificate.get('kind') != 'certified_webmainbench_scoring_v1' + or scoring_certificate.get('cohort_certificate_sha256') != digest(source / 'cohort-certificate.json') + or scoring_certificate.get('source_binding') != certificate['postflight']): + raise ValueError('Complete scoring certificate mismatch') + dataset = json.loads((source / 'dataset.json').read_text(encoding='utf-8')) + records = read_jsonl(source / 'results.jsonl') + six_binding = json.loads((source / 'six-method-binding.json').read_text(encoding='utf-8')) + fixed_profile_path = Path(__file__).resolve().parents[2] / 'evaluation/webmainbench-full/moli-fixed-report.json' + if (six_binding.get('kind') != 'webmainbench_six_version_full_scope_binding' + or six_binding.get('baseline_certificate_sha256') != digest(source / 'cohort-certificate.json') + or six_binding.get('fixed_profile_sha256') != digest(fixed_profile_path) + or six_binding.get('fixed_certificate_sha256') != digest(source / 'fixed-cohort-certificate.json') + or six_binding.get('fixed_scoring_certificate_sha256') != digest(source / 'fixed-scoring-certificate.json') + or six_binding.get('results_sha256') != digest(source / 'original-results.jsonl')): + raise ValueError('Six-version report evidence binding mismatch') + fixed_cert = json.loads((source / 'fixed-cohort-certificate.json').read_text(encoding='utf-8')) + fixed_profile = json.loads(fixed_profile_path.read_text(encoding='utf-8')) + if (fixed_cert.get('preflight') != fixed_cert.get('postflight') + or fixed_cert.get('profile_sha256') != digest(fixed_profile_path) + or fixed_cert.get('binary_source_commit') != fixed_profile['source_commit'] + or fixed_cert.get('measured_binary_sha256') != fixed_profile['binary_sha256'] + or six_binding.get('fixed_source_commit') != fixed_profile['source_commit']): + raise ValueError('Fixed Moli cohort was not certified') + providers = profile['providers'] + ['moli-fixed'] + display_profile = {**profile, 'providers': providers, + 'versions': {**profile['versions'], 'moli-fixed': '1.1.9'}} + cases = {case['id']: case for case in dataset['cases']} + if (len(cases) != 545 or len(records) != 545 * len(providers) + or {(row['provider'], row['case_id']) for row in records} != + {(provider, case_id) for provider in providers for case_id in cases}): + raise ValueError('Full WebMainBench result matrix mismatch') + validate_teds_revision(source) + content_binding_path = Path(__file__).resolve().parents[2] / 'evaluation/webmainbench-full/content-binding.json' + content_binding = json.loads(content_binding_path.read_text(encoding='utf-8')) + content_path = data_home() / 'report-assets/webmainbench-full' / content_binding['relative_path'] + if digest(content_path) != content_binding['sha256']: + raise ValueError('Report prose differs from the compact content binding') + failure_binding_path = Path(__file__).resolve().parents[2] / 'evaluation/webmainbench-full/failure-analysis-binding.json' + failure_binding = json.loads(failure_binding_path.read_text(encoding='utf-8')) + failure_path = data_home() / 'report-assets/webmainbench-full' / failure_binding['relative_path'] + if digest(failure_path) != failure_binding['sha256']: + raise ValueError('Failure analysis differs from the compact review binding') + failure_analysis = json.loads(failure_path.read_text(encoding='utf-8')) + correction_binding_path = profile_path.parent / 'case-correction-binding.json' + correction_root = None + corrected_keys = set() + if correction_binding_path.exists(): + from .webmainbench_correction import validate as validate_case_correction + correction_binding = json.loads(correction_binding_path.read_text()) + correction_root = data_home() / correction_binding['relative_path'] + if digest(correction_root / 'certificate.json') != correction_binding['sha256']: + raise ValueError('Case correction differs from bound certificate') + _, corrected_rows, _ = validate_case_correction(correction_root, records, digest(source / 'results.jsonl')) + corrected_keys = {(r['provider'], r['case_id']) for r in corrected_rows} + failures = {(row['provider'], row['case_id']) for row in records + if webmainbench_full_machine_issue(source, row)} - corrected_keys + reviewed = {(row['provider'], row['case_id']) for row in failure_analysis['reviewed_failures']} + if (failure_analysis.get('kind') != 'webmainbench_full_failure_analysis_v1' + or failure_analysis.get('cohort_certificate_sha256') != digest(source / 'cohort-certificate.json') + or reviewed != {item for item in failures if item[0] != 'moli-fixed'} + or len(reviewed) != len(failure_analysis['reviewed_failures']) + or any(not all(isinstance(row.get(key), str) and row[key].strip() + for key in ('category_label', 'cause', 'treatment', 'status')) + for row in failure_analysis['reviewed_failures'])): + raise ValueError('Failure analysis does not cover every measured request failure') + for case_id, case in cases.items(): + if digest(contained_path(source, f'references/{case_id}.md')) != case['reference_sha256']: + raise ValueError('Reference changed: ' + case_id) + if digest(contained_path(source, f'sources/{case_id}.html.txt')) != case['input_html_sha256']: + raise ValueError('Frozen page changed: ' + case_id) + for row in records: + if digest(contained_path(source, row['artifact_path'])) != row['output_sha256']: + raise ValueError('Output changed: ' + row['attempt_id']) + output.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix='.webmainbench-full-build-', dir=output.parent)) + try: + for name in ('artifacts', 'references', 'sources', 'fixture-certificates', 'correction-source'): + shutil.copytree(source / name, staging / name) + for name in ('dataset.json', 'results.jsonl', 'original-results.jsonl', + 'physical-attempts.jsonl', 'execution.json', 'score-summaries.json', + 'cohort-certificate.json', 'current-report.json', + 'scoring-certificate.json', + 'teds-correction.json', 'teds-correction-review.json', + 'six-method-binding.json', 'fixed-cohort-certificate.json', + 'fixed-scoring-certificate.json', 'moli-fixed-report.json'): + shutil.copy2(source / name, staging / name) + shutil.copy2(content_path, staging / 'report-content.json') + shutil.copy2(failure_path, staging / 'failure-analysis.json') + shutil.copy2(Path(__file__).resolve().parents[2] / 'research/provider-adoption.json', staging / 'provider-adoption.json') + summaries = json.loads((source / 'score-summaries.json').read_text(encoding='utf-8')) + if correction_root: + from .webmainbench_correction import apply as apply_case_correction + records, summaries = apply_case_correction(correction_root, staging, records, summaries) + illustration_binding = profile_path.parent / 'illustrations.json' + if illustration_binding.exists(): + (staging / 'assets').mkdir(exist_ok=True) + for asset in json.loads(illustration_binding.read_text())['assets']: + asset_source = data_home() / 'report-assets/webmainbench-full' / asset['name'] + if digest(asset_source) != asset['sha256']: + raise ValueError('Report illustration changed: ' + asset['name']) + shutil.copy2(asset_source, staging / 'assets' / asset['name']) + generate_webmainbench_full(staging, dataset, records, + summaries, display_profile) + from scripts.check_webmainbench_full_report import check as check_webmainbench_full + check_webmainbench_full(staging) + if output.exists(): + backup = Path(tempfile.mkdtemp(prefix='.report-swap-', dir=output.parent)) / 'report' + output.rename(backup) + try: + staging.rename(output) + except BaseException: + backup.rename(output) + raise + shutil.rmtree(backup.parent) + else: + staging.rename(output) + finally: + if staging.exists(): + shutil.rmtree(staging) + return output / 'index.html' + + def build_wcxb_report(source: Path, corpus: Path, sample_path: Path, profile: dict, output: Path) -> Path: """Build the one current report from a fully certified WCXB matrix.""" from scripts.run_webmainbench_benchmark import WCXB_PROVIDERS, load_wcxb_source diff --git a/harness/web_fetch/webmainbench_correction.py b/harness/web_fetch/webmainbench_correction.py new file mode 100644 index 000000000..200180975 --- /dev/null +++ b/harness/web_fetch/webmainbench_correction.py @@ -0,0 +1,94 @@ +"""Validate and apply a certified complete method-by-case correction.""" +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +import shutil + +from .safety import contained_path + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def read_rows(path): + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def validate(root: Path, original_rows: list[dict], expected_parent: str) -> tuple[dict, list, list]: + certificate = json.loads((root / 'certificate.json').read_text()) + plan = certificate['preflight'] + if (certificate.get('kind') != 'webmainbench_origin_correction_v1' + or plan != certificate.get('postflight') + or plan['parent_results_sha256'] != expected_parent + or digest(root / 'results.jsonl') != certificate['results_sha256'] + or digest(root / 'physical-attempts.jsonl') != certificate['physical_sha256']): + raise ValueError('Case correction certificate mismatch') + rows = read_rows(root / 'results.jsonl') + physical = read_rows(root / 'physical-attempts.jsonl') + originals = {r['provider']: r for r in original_rows if r['case_id'] == plan['case_id']} + if (set(plan['providers']) != set(originals) or len(rows) != len(originals) + or {r['provider'] for r in rows} != set(originals)): + raise ValueError('Case correction must cover every method exactly once') + for row in rows: + old = originals[row['provider']] + if (row['case_id'] != plan['case_id'] or row['supersedes_attempt_id'] != old['attempt_id'] + or any(row[key] != old[key] for key in ('input_html_sha256', 'reference_sha256')) + or any(row['provider_metadata'].get(key) != old['provider_metadata'].get(key) + for key in ('binary_sha256', 'browser_version', 'extractor_version', 'converter_sha256'))): + raise ValueError('Case correction changes the frozen input or provider identity') + if digest(contained_path(root, row['artifact_path'])) != row['output_sha256']: + raise ValueError('Corrected output changed') + calls = [c for c in physical if c['provider'] == row['provider']] + if len(calls) != row['physical_attempt_count'] or not calls: + raise ValueError('Correction physical calls incomplete') + if len({c['attempt_id'] for c in physical}) != len(physical): + raise ValueError('Duplicate correction call identity') + for call in physical: + if (call['track_id'] != plan['case_id'] or call['provider'] not in originals + or digest(contained_path(root, call['physical_artifact_path'])) != call['output_sha256']): + raise ValueError('Correction physical output changed') + return certificate, rows, physical + + +def apply(root: Path, staging: Path, original_rows: list, summaries: dict) -> tuple[list, dict]: + from scripts.score_webmainbench_results import aggregate + from scripts.apply_webmainbench_case_correction import dimensions + certificate, replacements, calls = validate(root, original_rows, digest(staging / 'results.jsonl')) + lookup = {r['provider']: r for r in replacements} + case_id = certificate['preflight']['case_id'] + result = [lookup[r['provider']] if r['case_id'] == case_id else r for r in original_rows] + for row in replacements: + path = contained_path(staging, row['artifact_path']) + path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(contained_path(root, row['artifact_path']), path) + for call in calls: + path = contained_path(staging, call['physical_artifact_path']) + shutil.copy2(contained_path(root, call['physical_artifact_path']), path) + # Retain previous physical artifacts and calls for audit, outside the current logical samples. + previous = read_rows(staging / 'physical-attempts.jsonl') + history = [r for r in previous if r['track_id'] == case_id] + current_calls = [r for r in previous if r['track_id'] != case_id] + calls + for filename, values in [('results.jsonl', result), ('physical-attempts.jsonl', current_calls), + ('superseded-case-results.jsonl', [r for r in original_rows if r['case_id'] == case_id]), + ('superseded-case-calls.jsonl', history)]: + (staging / filename).write_text(''.join(json.dumps(r, ensure_ascii=False) + '\n' for r in values)) + revised = copy.deepcopy(summaries) + for provider, summary in revised.items(): + selected = [r for r in result if r['provider'] == provider] + summary['official_aggregate'] = aggregate(selected) + summary['dimensions'] = dimensions(selected) + summary['request_successes'] = sum(r['ok'] for r in selected) + summary['request_success_rate'] = summary['request_successes'] / len(selected) + (staging / 'score-summaries.json').write_text(json.dumps(revised, ensure_ascii=False, indent=2) + '\n') + receipt = {**certificate, 'current_results_sha256': digest(staging / 'results.jsonl'), + 'superseded_execution_ms': sum(r['elapsed_ms'] for r in history), + 'superseded_physical_calls': len(history), + 'superseded_case_results_sha256': digest(staging / 'superseded-case-results.jsonl'), + 'superseded_case_calls_sha256': digest(staging / 'superseded-case-calls.jsonl'), + 'score_summaries_sha256': digest(staging / 'score-summaries.json')} + (staging / 'case-correction.json').write_text(json.dumps(receipt, ensure_ascii=False, indent=2) + '\n') + return result, revised diff --git a/research/report-changes.json b/research/report-changes.json index 6b3f6f07c..e0fbc3dcc 100644 --- a/research/report-changes.json +++ b/research/report-changes.json @@ -1275,6 +1275,94 @@ } ], "releases": [ + { + "id": "webmainbench-545-six-method-origin-correction-20260924", + "date": "2026-09-24", + "comparison_kinds": [ + "webmainbench_frozen_html_to_markdown" + ], + "title": "纠正原网址替换导致的假失败并恢复六方法分析图", + "summary": "狼人杀规则页保留原网址后,六种方法使用原受测版本完成认证补测并生成正文;六条新结果统一替换旧结果,修复版 Moli 正文交付由 544/545 更正为 545/545,当前机器问题由 29 条降至 25 条。恢复六方法结果图和错误机制图,主表、同页结果、错误归因与汇总分同步更新。", + "changes": [ + [ + "测试环境", + "localhost 替换触发页面跳回原站,隔离规则造成假失败。", + "保留原网址并继续使用冻结 HTML、阻断外部资源;六方法同页认证补测。", + "新增六次调用,其他 544 页不变;旧调用与原分保留。" + ], + [ + "分析图", + "五方法图未随六方法报告更新,后续生成器未引用旧图。", + "以绑定哈希的六方法结果图及错误机制图重新接入解读页。", + "发布检查拒绝丢图或图片哈希不一致。" + ] + ], + "i18n": { + "en": { + "title": "Correct origin substitution and restore six-method analysis figures", + "summary": "A certified six-method rerun preserves the original URL and frozen HTML. All six produce body text; fixed Moli now delivers 545/545 pages. The current matrix has 25 machine issues, and the main tables, same-case results, attribution, and aggregate scores are updated together.", + "changes": [ + [ + "Test environment", + "Replacing the origin with localhost triggered a redirect back to the live site, which the isolation rules then blocked as a false failure.", + "Preserve the original URL while continuing to serve frozen HTML and block external resources; rerun the same page for all six methods as one certified correction.", + "Add six calls while leaving the other 544 pages unchanged; retain the superseded calls and original scores." + ], + [ + "Analysis figures", + "The five-method figures were not updated for the six-method report, and the later generator no longer referenced them.", + "Reconnect hash-bound six-method result and error-mechanism figures to the interpretation page.", + "The release gate rejects a missing figure or an illustration whose hash has changed." + ] + ] + } + } + }, + { + "id": "2026-09-24-webmainbench-target-body-population", + "comparison_kinds": ["webmainbench_frozen_html_to_markdown"], + "date": "2026-09-24", + "title": "区分请求成功与目标正文", + "summary": "核心表和条件耗时改按实际生成目标正文的页面统计;浏览器错误页、空输出和请求失败不再计入正文页数。评分、原始输出和 3,270 条逻辑结果不变。", + "changes": [["正文与耗时口径", "进程成功退出会把 ERR_ACCESS_DENIED 等浏览器错误页计入“返回成功”,并进入成功页条件耗时。", "从已保存的逐页诊断生成目标正文标记;核心表、条件耗时和 Moli 修复前后对照共用该标记。", "Chrome 的错误页不再算目标正文;全部调用耗时、质量评分、重试成本和冻结证据保持不变。"]], + "i18n": {"en": {"title": "Separate successful requests from target-body delivery", "summary": "The core table and conditional latency now count pages that actually produced target body content. Browser error pages, empty output, and request failures are excluded. Scores, raw outputs, and all 3,270 logical results are unchanged.", "changes": [["Body and latency population", "A zero-exit process could count an ERR_ACCESS_DENIED browser page as a successful return and include it in conditional success latency.", "Derive target-body delivery from the retained per-page diagnosis and use it consistently in the core table, conditional latency, and Moli before/after comparison.", "The Chrome error page no longer counts as target body. All-call latency, quality scores, retry cost, and frozen evidence remain unchanged."]]}} + }, + { + "id": "2026-09-24-webmainbench-six-versions-certified", + "comparison_kinds": ["webmainbench_frozen_html_to_markdown"], + "date": "2026-09-24", + "title": "将修复版 Moli 纳入完整六方法比较", + "summary": "修复版 Moli 按同一批 545 页完成独立认证运行;主表、结果清单和同页对照均展示六种方法,共 3,270 条结果。", + "changes": [["六方法结果", "修复版先前只在附注页出现,主表与同页证据仍只有五种方法。", "将修复版作为独立第六方法纳入完整矩阵,两轮结果使用同一表格结构修正口径。", "逐页输出和失败可直接对照;修复版另日运行,其耗时不参加原五方法的速度标优。"]], + "i18n": {"en": {"title": "Include repaired Moli in the complete six-version comparison", "summary": "A separate certified run measured repaired Moli on the same 545 pages. The main table, result inventory, and same-case comparisons now include all six versions and 3,270 results.", "changes": [["Six-version results", "The repaired build previously appeared only on a follow-up page, outside the main result matrix.", "Include it as a sixth version, and apply the same corrected table-structure scoring to both complete cohorts.", "Page outputs and failures are directly inspectable; the later timing run is not ranked against the original five."]]}} + }, + { + "id": "2026-09-24-webmainbench-moli-repair-545-followup", + "comparison_kinds": ["webmainbench_frozen_html_to_markdown"], + "date": "2026-09-24", + "title": "补充 Moli 修复后的 545 页复测", + "summary": "同一批冻结输入上的 Moli 单方法复测由 542/545 页返回成功增至 544/545 页;两条解析器崩溃恢复,一条隔离环境阻断的跳转仍失败。", + "changes": [["修复复测", "原五方法报告只记录修复前的 Moli 结果,读者无法看到后续解析器修复的实际效果。", "单列 545 页复测、同口径四项原评分、恢复页面的完整输出与剩余失败。", "五方法认证结果和排名未改;恢复的 Autochina360 页仍夹杂大量无关链接,全文分 22.8%。不同时间的耗时和未经同一 TEDS 修正的综合分不作前后比较。"]], + "i18n": {"en": {"title": "Add the 545-page Moli repair follow-up", "summary": "In a Moli-only rerun of the same frozen pages, request success rose from 542/545 to 544/545. Two parser crashes recovered; one redirect blocked by the isolated fixture still failed.", "changes": [["Repair follow-up", "The five-method report only showed Moli before the parser repair.", "Present the full-page rerun, four original-scorer metrics, complete outputs for recovered pages, and the remaining failure.", "The certified five-method ranking is unchanged. The recovered Autochina360 output still contains many unrelated links and scores 22.8% on full text; timing across dates and aggregate scores without the same TEDS correction are not compared."]]}} + }, + { + "id": "2026-09-23-webmainbench-error-attribution-and-case-comparison", + "comparison_kinds": ["webmainbench_frozen_html_to_markdown"], + "date": "2026-09-23", + "title": "按方法定位机器问题并展示同页完整输出", + "summary": "机器问题图与解读页交叉列出错误类型、方法和可确认的归属。逐题对照页在同一处显示人工主内容答案与五种方法的完整输出,结果清单可按复核原因筛选。", + "changes": [["错误归属与逐题阅读", "汇总数不能直接显示每种错误发生在哪个方法,逐题页只列分数而需分别打开答案和输出。", "增加错误类型与方法矩阵、归属说明及原因筛选;同页显示参考答案和五种完整结果。", "545 题输入、2725 条结果、分数、耗时和已复核的 28 条机器问题均不变。"]], + "i18n": {"en": {"title": "Attribute machine issues by method and show complete same-page outputs", "summary": "The issue graphic and interpretation cross-tabulate error type, method, and evidenced attribution. Each case comparison displays the human main-content reference beside all five complete outputs, and the result inventory can filter by reviewed cause.", "changes": [["Issue attribution and case reading", "Aggregate counts did not show which method encountered each issue, and case pages listed scores without the reference and outputs in one view.", "Add an error-type-by-method matrix, attribution notes, and cause filtering; display the reference and five full outputs on each case page.", "The 545 inputs, 2,725 results, scores, timings, and 28 reviewed machine issues remain unchanged."]]}} + }, + { + "id": "2026-09-23-webmainbench-complete-545-five-methods", + "comparison_kinds": ["webmainbench_frozen_html_to_markdown"], + "date": "2026-09-23", + "title": "发布 WebMainBench 完整 545 题五方法对照", + "summary": "以同一批 545 个官方冻结 HTML 和人工参考,实测五种稳定版浏览器方法。新报告提供逐题输入、输出、评分、失败证据和已复核的机器问题分析。", + "changes": [["完整评测与评分", "先前公开报告只覆盖 200 题,无法代表完整数据集。官方 TEDS 实现还可能高估部分表格相似度。", "认证五种方法各 545 题,并用保留原始输出和官方分数的结构化树比较校正 TEDS;报告同时展示请求重试和失败分类。", "共 2725 个逻辑结果、2747 次实际调用;292 个 TEDS 分数校正。28 个机器问题已逐条复核,其余 2697 个结果未逐条人工复核。"]], + "i18n": {"en": {"title": "Publish the complete 545-case WebMainBench comparison", "summary": "Five stable browser methods were measured against the same 545 official frozen HTML inputs and human references. The new report exposes case inputs, outputs, scores, failure evidence, and reviewed machine-issue analysis.", "changes": [["Full evaluation and scoring", "The earlier public report covered only 200 cases, and the official TEDS implementation could overstate some table similarities.", "Certify 545 cases for each of five methods and correct TEDS with structured tree comparison while preserving raw outputs and official scores; show retries and failure classes.", "There are 2,725 logical results and 2,747 physical calls; 292 TEDS scores were corrected. All 28 machine issues were reviewed individually, while the other 2,697 results were not individually human reviewed."]]}} + }, { "id": "2026-09-20-wcxb-measured-mode-labels", "comparison_kinds": ["wcxb_frozen_html_to_markdown"], diff --git a/scripts/assemble_webmainbench_full.py b/scripts/assemble_webmainbench_full.py new file mode 100644 index 000000000..884ceac2b --- /dev/null +++ b/scripts/assemble_webmainbench_full.py @@ -0,0 +1,163 @@ +"""Package only certified, scored WebMainBench-545 evidence for the full report.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from harness.web_fetch.safety import contained_path +from scripts.run_certified_webmainbench_full import digest, read_jsonl, verify_shard +from scripts.run_webmainbench_benchmark import load_source + + +def public_value(value): + """Remove machine-specific absolute paths from the public evidence copy.""" + if isinstance(value, dict): + return {key: public_value(item) for key, item in value.items()} + if isinstance(value, list): + return [public_value(item) for item in value] + if isinstance(value, str): + value = re.sub(r"/Users/[^/\s:]+", "/redacted-home", value) + value = re.sub(r"/home/[^/\s:]+", "/redacted-home", value) + value = re.sub(r"/private/tmp/[^\s:]+", "/redacted-temp", value) + value = re.sub(r"/(?:var/)?tmp/[^\s:]+", "/redacted-temp", value) + return value + + +def copy_bound(root: Path, output: Path, relative: str, expected: str) -> None: + source = contained_path(root, relative) + if (root / relative).is_symlink() or digest(source) != expected: + raise ValueError("Evidence artifact changed: " + relative) + target = contained_path(output, relative) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + +def assemble(root: Path, output: Path, source: Path, profile_path: Path) -> None: + if output.exists(): + raise ValueError("Output already exists; report evidence is immutable") + if root.is_symlink() or source.is_symlink() or profile_path.is_symlink(): + raise ValueError("Symlinked aggregate input is forbidden") + profile = json.loads(profile_path.read_text(encoding="utf-8")) + cert_path = root / "cohort-certificate.json" + certificate = json.loads(cert_path.read_text(encoding="utf-8")) + scoring_certificate_path = root / "scoring-certificate.json" + scoring_certificate = json.loads(scoring_certificate_path.read_text(encoding="utf-8")) + if (certificate.get("kind") != "certified_webmainbench_545_full_v1" + or certificate.get("profile_sha256") != digest(profile_path) + or certificate.get("preflight") != certificate.get("postflight") + or certificate["preflight"]["source_sha256"] != profile["source_dataset_sha256"] + or [row["provider"] for row in certificate["providers"]] != profile["providers"]): + raise ValueError("Complete-cohort certificate or profile mismatch") + if (scoring_certificate.get("kind") != "certified_webmainbench_scoring_v1" + or scoring_certificate.get("cohort_certificate_sha256") != digest(cert_path) + or scoring_certificate.get("source_binding") != certificate["postflight"] + or [row["provider"] for row in scoring_certificate["providers"]] != profile["providers"]): + raise ValueError("Complete scoring certificate differs from the certified cohort") + source_records = load_source(source) + ids = {row["track_id"] for row in source_records} + if len(ids) != 545 or len(source_records) != profile["case_count"]: + raise ValueError("Source case inventory mismatch") + results: list[dict] = [] + physical: list[dict] = [] + summaries: dict[str, dict] = {} + executions: list[dict] = [] + output.mkdir(parents=True) + (output / "references").mkdir() + (output / "sources").mkdir() + (output / "fixture-certificates").mkdir() + for record in source_records: + case_id = record["track_id"] + (output / "references" / (case_id + ".md")).write_text( + record["groundtruth_content"], encoding="utf-8") + (output / "sources" / (case_id + ".html.txt")).write_text( + record["html"], encoding="utf-8") + for expected, scored_receipt in zip(certificate["providers"], scoring_certificate["providers"]): + provider = expected["provider"] + shard = root / provider + if shard.is_symlink() or any(path.is_symlink() for path in shard.rglob("*")): + raise ValueError(provider + ": symlinked evidence is forbidden") + if verify_shard(shard, provider, ids, profile) != expected: + raise ValueError(provider + ": certificate differs from measured shard") + rows = read_jsonl(shard / "scored-results.jsonl") + if (scored_receipt["scored_results_sha256"] != digest(shard / "scored-results.jsonl") + or scored_receipt["score_summary_sha256"] != digest(shard / "score-summary.json") + or scored_receipt["logical_results"] != profile["case_count"]): + raise ValueError(provider + ": scoring certificate differs from scored shard") + if (len(rows) != 545 or {row["track_id"] for row in rows} != ids + or len({row["track_id"] for row in rows}) != 545 + or any(row["provider"] != provider for row in rows)): + raise ValueError(provider + ": incomplete scored matrix") + summary = json.loads((shard / "score-summary.json").read_text(encoding="utf-8")) + if (summary.get("case_count") != 545 or summary.get("provider") != provider + or summary.get("scorer", {}).get("metric_source_sha256") != + scoring_certificate["scorer_source_sha256"]): + raise ValueError(provider + ": score summary scope mismatch") + logical = {row["track_id"]: row for row in read_jsonl(shard / "results.jsonl")} + for row in rows: + base = logical[row["track_id"]] + if {k: v for k, v in row.items() if k != "metrics"} != base: + raise ValueError(provider + ": scoring changed measured result") + copy_bound(shard, output, row["artifact_path"], row["output_sha256"]) + results.append(public_value({**row, "case_id": row["track_id"]})) + attempts = read_jsonl(shard / "physical-attempts.jsonl") + for attempt in attempts: + copy_bound(shard, output, attempt["physical_artifact_path"], attempt["output_sha256"]) + physical.extend(public_value(attempts)) + execution = json.loads((shard / "execution.json").read_text(encoding="utf-8")) + ca = execution.get("fixture_ca_artifact") + if ca: + ca_source = contained_path(shard, ca) + if digest(ca_source) != execution["network_isolation_probe"]["fixture_ca_sha256"]: + raise ValueError(provider + ": fixture CA changed") + ca_target = output / "fixture-certificates" / (provider + "-ca.pem") + shutil.copy2(ca_source, ca_target) + execution["assembled_fixture_ca_path"] = str(ca_target.relative_to(output)) + executions.append(execution) + summaries[provider] = summary + expected_pairs = {(provider, case_id) for provider in profile["providers"] for case_id in ids} + if (len(results) != len(expected_pairs) + or {(row["provider"], row["case_id"]) for row in results} != expected_pairs + or len({row["attempt_id"] for row in results}) != len(results) + or len({row["attempt_id"] for row in physical}) != len(physical)): + raise ValueError("Full result or physical attempt matrix mismatch") + cases = [{"id": row["track_id"], "name": row["track_id"], "url": row["url"], + "category": "webmainbench", "track": "static_html", "meta": row["meta"], + "input_html_sha256": hashlib.sha256(row["html"].encode()).hexdigest(), + "reference_sha256": hashlib.sha256(row["groundtruth_content"].encode()).hexdigest(), + "reference_chars": len(row["groundtruth_content"])} for row in source_records] + dataset = {"schema_version": 1, "tool": "web_fetch", "id": profile["dataset_id"], + "name": "WebMainBench-545 full · five Web Fetch methods", + "source_dataset_sha256": profile["source_dataset_sha256"], + "cases": cases, "coverage_plan": {"static_html": profile["providers"]}, + "comparison": profile} + (output / "dataset.json").write_text(json.dumps(dataset, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + (output / "score-summaries.json").write_text(json.dumps(summaries, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + (output / "execution.json").write_text(json.dumps({"batches": executions}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + (output / "results.jsonl").write_text("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in results), encoding="utf-8") + (output / "physical-attempts.jsonl").write_text("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in physical), encoding="utf-8") + shutil.copy2(cert_path, output / "cohort-certificate.json") + shutil.copy2(scoring_certificate_path, output / "scoring-certificate.json") + shutil.copy2(profile_path, output / "current-report.json") + print(f"Assembled {len(results)} results and {len(physical)} physical calls", flush=True) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--dataset", type=Path, required=True) + parser.add_argument("--profile", type=Path, default=ROOT / "evaluation/webmainbench-full/current-report.json") + args = parser.parse_args() + assemble(args.source_root, args.output, args.dataset, args.profile) + + +if __name__ == "__main__": + main() diff --git a/scripts/assemble_webmainbench_six_methods.py b/scripts/assemble_webmainbench_six_methods.py new file mode 100644 index 000000000..11e8a26f4 --- /dev/null +++ b/scripts/assemble_webmainbench_six_methods.py @@ -0,0 +1,138 @@ +"""Join two independently certified full-scope runs for a six-version report.""" +from __future__ import annotations + +import argparse +import copy +import json +from pathlib import Path +import shutil +import sys + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from scripts.run_certified_webmainbench_full import digest, read_jsonl, verify_shard +from scripts.score_certified_webmainbench_full import verify_score +from harness.web_fetch.safety import contained_path + +FIXED = "moli-fixed" + + +def require_unique_attempt_ids(rows: list[dict], label: str) -> None: + attempt_ids = [row.get("attempt_id") for row in rows] + if None in attempt_ids or len(set(attempt_ids)) != len(attempt_ids): + raise ValueError(f"{label} attempt identity is not unique") + + +def assemble(baseline: Path, candidate: Path, output: Path, scorer: Path) -> None: + if output.exists(): + raise ValueError("Composite evidence is immutable") + base_profile_path = ROOT / "evaluation/webmainbench-full/current-report.json" + fixed_profile_path = ROOT / "evaluation/webmainbench-full/moli-fixed-report.json" + base_profile = json.loads(base_profile_path.read_text()) + fixed_profile = json.loads(fixed_profile_path.read_text()) + base_cert = json.loads((baseline / "cohort-certificate.json").read_text()) + fixed_cert = json.loads((candidate / "cohort-certificate.json").read_text()) + score_cert = json.loads((candidate / "scoring-certificate.json").read_text()) + if (base_cert["profile_sha256"] != digest(base_profile_path) + or fixed_cert["profile_sha256"] != digest(fixed_profile_path) + or fixed_cert["preflight"] != fixed_cert["postflight"] + or fixed_cert["preflight"] != base_cert["postflight"] + or fixed_cert.get("binary_source_commit") != fixed_profile.get("source_commit") + or fixed_cert.get("measured_binary_sha256") != fixed_profile.get("binary_sha256") + or score_cert["cohort_certificate_sha256"] != digest(candidate / "cohort-certificate.json") + or base_profile["source_dataset_sha256"] != fixed_profile["source_dataset_sha256"]): + raise ValueError("Independent complete-run bindings differ") + if any(path.is_symlink() for root in (baseline, candidate) for path in root.rglob("*")): + raise ValueError("Symlinked evidence is forbidden") + dataset = json.loads((baseline / "dataset.json").read_text()) + ids = {item["id"] for item in dataset["cases"]} + shard = candidate / "moli" + if verify_shard(shard, "moli", ids, fixed_profile) != fixed_cert["providers"][0]: + raise ValueError("Fixed run differs from certificate") + if verify_score(shard, "moli", ids, scorer, fixed_profile) != score_cert["providers"][0]: + raise ValueError("Fixed scores differ from certificate") + measured = read_jsonl(shard / "scored-results.jsonl") + if len(measured) != 545 or {row["track_id"] for row in measured} != ids: + raise ValueError("Fixed run is incomplete") + shutil.copytree(baseline, output) + def alias(row: dict) -> dict: + row = copy.deepcopy(row) + row["provider"] = FIXED + row["attempt_id"] = row["attempt_id"].replace("moli__", FIXED + "__", 1) + for key in ("artifact_path", "physical_artifact_path"): + if key in row: + row[key] = row[key].replace("artifacts/moli/", f"artifacts/{FIXED}/", 1) + return row + rows = [alias({**row, "case_id": row["track_id"]}) for row in measured] + attempts = [alias(row) for row in read_jsonl(shard / "physical-attempts.jsonl")] + if len(rows) != 545: + raise ValueError("Fixed logical result matrix is incomplete") + require_unique_attempt_ids(rows, "Fixed logical result") + for row in measured: + source = contained_path(shard, row["artifact_path"]) + if digest(source) != row["output_sha256"]: + raise ValueError("Fixed output changed") + target = contained_path(output, row["artifact_path"].replace("artifacts/moli/", f"artifacts/{FIXED}/", 1)) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + for row in read_jsonl(shard / "physical-attempts.jsonl"): + source = contained_path(shard, row["physical_artifact_path"]) + if digest(source) != row["output_sha256"]: + raise ValueError("Fixed physical output changed") + target = contained_path(output, row["physical_artifact_path"].replace("artifacts/moli/", f"artifacts/{FIXED}/", 1)) + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + combined = read_jsonl(baseline / "results.jsonl") + rows + if len(combined) != 3270: + raise ValueError("Six-version result matrix is incomplete") + require_unique_attempt_ids(combined, "Six-version logical result") + (output / "results.jsonl").write_text("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in combined)) + physical = read_jsonl(baseline / "physical-attempts.jsonl") + attempts + require_unique_attempt_ids(physical, "Six-version physical") + (output / "physical-attempts.jsonl").write_text("".join(json.dumps(row, ensure_ascii=False) + "\n" for row in physical)) + summary = json.loads((baseline / "score-summaries.json").read_text()) + summary[FIXED] = {**json.loads((shard / "score-summary.json").read_text()), "provider": FIXED} + (output / "score-summaries.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2) + "\n") + execution = json.loads((baseline / "execution.json").read_text()) + fixed_execution = json.loads((shard / "execution.json").read_text()) + if fixed_execution["implementation_identity"]["binary_sha256"] != fixed_profile["binary_sha256"]: + raise ValueError("Fixed binary changed") + fixed_execution["provider"] = FIXED + execution["batches"].append(fixed_execution) + (output / "execution.json").write_text(json.dumps(execution, ensure_ascii=False, indent=2) + "\n") + dataset["coverage_plan"]["static_html"].append(FIXED) + dataset["name"] = "WebMainBench-545 full · six Web Fetch versions" + (output / "dataset.json").write_text(json.dumps(dataset, ensure_ascii=False, indent=2) + "\n") + shutil.copy2(candidate / "cohort-certificate.json", output / "fixed-cohort-certificate.json") + shutil.copy2(candidate / "scoring-certificate.json", output / "fixed-scoring-certificate.json") + shutil.copy2(fixed_profile_path, output / "moli-fixed-report.json") + binding = {"schema_version": 1, "kind": "webmainbench_six_version_full_scope_binding", + "baseline_profile_sha256": digest(base_profile_path), + "baseline_certificate_sha256": digest(baseline / "cohort-certificate.json"), + "fixed_profile_sha256": digest(fixed_profile_path), + "fixed_certificate_sha256": digest(candidate / "cohort-certificate.json"), + "fixed_scoring_certificate_sha256": digest(candidate / "scoring-certificate.json"), + "fixed_scored_results_sha256": digest(shard / "scored-results.jsonl"), + "fixed_binary_sha256": fixed_profile["binary_sha256"], + "fixed_source_commit": fixed_profile["source_commit"], + # This is the complete six-method matrix before TEDS recomputation. + # correct_webmainbench_teds.py later preserves these exact bytes as + # original-results.jsonl, which is what the build and release gates verify. + "results_sha256": digest(output / "results.jsonl"), + "logical_results": len(combined), "physical_calls": len(physical)} + (output / "six-method-binding.json").write_text(json.dumps(binding, ensure_ascii=False, indent=2) + "\n") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--baseline", required=True, type=Path) + parser.add_argument("--candidate", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--scorer", required=True, type=Path) + args = parser.parse_args() + assemble(args.baseline, args.candidate, args.output, args.scorer) + + +if __name__ == "__main__": + main() diff --git a/scripts/build_report.py b/scripts/build_report.py index 0e603dcfa..a3028c9c8 100644 --- a/scripts/build_report.py +++ b/scripts/build_report.py @@ -3,7 +3,7 @@ from pathlib import Path ROOT=Path(__file__).resolve().parents[1] sys.path.insert(0,str(ROOT)) -from harness.web_fetch.report_build import build_report, build_public_benchmark_research, build_wcxb_report +from harness.web_fetch.report_build import build_report, build_public_benchmark_research, build_wcxb_report, build_webmainbench_full_report from harness.web_fetch.storage import ( current_evidence_dir, require_canonical_inputs, @@ -23,9 +23,15 @@ parser.add_argument('--wcxb-source',type=Path) parser.add_argument('--wcxb-sample',type=Path) parser.add_argument('--wcxb',action='store_true',help='Build the independently bound WCXB report') + parser.add_argument('--webmainbench-full',action='store_true',help='Build the separately certified complete 545-case report') args=parser.parse_args() - if args.profile and args.wcxb: - parser.error('--profile and --wcxb are mutually exclusive') + if sum(bool(value) for value in (args.profile, args.wcxb, args.webmainbench_full)) > 1: + parser.error('--profile, --wcxb and --webmainbench-full are mutually exclusive') + if args.webmainbench_full: + if not args.evidence or args.include_english: + parser.error('--webmainbench-full requires --evidence and generates Chinese only') + print(build_webmainbench_full_report(args.evidence,args.output or ROOT/'reports/webmainbench-full')) + raise SystemExit(0) if args.public_benchmark_research: if args.evidence: parser.error('--evidence and --public-benchmark-research are mutually exclusive') diff --git a/scripts/certify_webmainbench_case_correction.py b/scripts/certify_webmainbench_case_correction.py new file mode 100644 index 000000000..ea705fee0 --- /dev/null +++ b/scripts/certify_webmainbench_case_correction.py @@ -0,0 +1,126 @@ +"""Certify an origin-preserving case correction against a frozen six-method run.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import types + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +from scripts.score_webmainbench_results import load_records, official_calculator, metric_dict +from scripts.correct_webmainbench_teds import corrected_distance +from scripts.run_certified_webmainbench_full import verify_shard + + +def digest(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def rows(path): + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def write(path, value): + path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n') + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + for name in ('source', 'dataset', 'scorer', 'output', 'moli-binary', 'fixed-binary'): + parser.add_argument('--' + name, type=Path, required=True) + parser.add_argument('--case-id', required=True) + args = parser.parse_args(argv) + if args.output.exists(): + raise ValueError('Correction destination already exists') + originals = rows(args.source / 'results.jsonl') + selected = {r['provider']: r for r in originals if r['case_id'] == args.case_id} + providers = json.loads((args.source / 'dataset.json').read_text())['coverage_plan']['static_html'] + if set(selected) != set(providers) or len(providers) != 6: + raise ValueError('Correction requires all six methods') + source_case = load_records(args.dataset)[args.case_id] + binaries = {'moli': args.moli_binary, 'moli-fixed': args.fixed_binary} + for name, binary in binaries.items(): + if digest(binary) != selected[name]['provider_metadata']['binary_sha256']: + raise ValueError('Binary differs from frozen cohort: ' + name) + plan = {'kind': 'webmainbench_origin_correction_v1', 'case_id': args.case_id, + 'providers': providers, 'parent_results_sha256': digest(args.source / 'results.jsonl'), + 'source_sha256': digest(args.dataset), 'timeout_seconds': 45, 'max_failure_retries': 1, + 'input_html_sha256': hashlib.sha256(source_case['html'].encode()).hexdigest(), + 'reference_sha256': hashlib.sha256(source_case['groundtruth_content'].encode()).hexdigest(), + 'reason': 'localhost substitution triggers the frozen page canonical-URL redirect; preserve the original URL while serving the same frozen bytes and blocking external resources', + 'selection': 'final physical call; all six methods selected before execution'} + args.output.mkdir(parents=True) + write(args.output / 'preflight.json', plan) + selection = args.output / 'case-ids.txt' + selection.write_text(args.case_id + '\n') + corrected = [] + physical = [] + for name in providers: + provider = 'moli' if name == 'moli-fixed' else name + shard = args.output / name + env = dict(os.environ) + if name in binaries: + env['MOLI_BINARY'] = str(binaries[name]) + version = selected[name]['provider_metadata'].get('browser_version', '').split() + version_args = ['--expected-version', version[-1]] if version and provider != 'kitesurf' else [] + subprocess.run([sys.executable, str(ROOT / 'scripts/run_webmainbench_benchmark.py'), + '--provider', provider, '--dataset', str(args.dataset), '--output', str(shard), + '--case-ids-file', str(selection), '--preserve-original-origin', + '--timeout', '45', '--max-failure-retries', '1', *version_args], env=env, cwd=ROOT, check=True) + verify_shard(shard, provider, {args.case_id}, {'case_count': 1, 'source_dataset_sha256': plan['source_sha256']}) + row = rows(shard / 'results.jsonl')[0] + old = selected[name] + for key in ('binary_sha256', 'browser_version', 'extractor_version', 'converter_sha256'): + if old['provider_metadata'].get(key) != row['provider_metadata'].get(key): + raise ValueError('Provider identity changed: ' + name + '/' + key) + for key in ('input_html_sha256', 'reference_sha256'): + if row[key] != plan[key]: + raise ValueError('Frozen input changed') + if not row['ok'] or not row['output_chars']: + raise ValueError('Correction still fails: ' + name) + calculator = official_calculator(args.scorer, shard / 'score-cache') + output = (shard / row['artifact_path']).read_text() + kwargs = dict(predicted_content=output, groundtruth_content=source_case['groundtruth_content'], + predicted_content_list=None, groundtruth_content_list=None) + row['official_metrics'] = {k: metric_dict(v) for k, v in calculator.calculate_all(**kwargs).items()} + calculator.metrics['table_TEDS']._tree_edit_distance = types.MethodType(corrected_distance, calculator.metrics['table_TEDS']) + row['metrics'] = {k: metric_dict(v) for k, v in calculator.calculate_all(**kwargs).items()} + row['provider'] = name + row['case_id'] = row['track_id'] + row['attempt_id'] = name + '__' + args.case_id + '__' + row['run_id'] + for call in rows(shard / 'physical-attempts.jsonl'): + call['provider'] = name + call['attempt_id'] = name + '__' + call['run_id'] + '__' + call['attempt_id'] + target = 'artifacts/' + name + '/' + call['run_id'] + '-' + Path(call['physical_artifact_path']).name + dest = args.output / target + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(shard / call['physical_artifact_path'], dest) + call['physical_artifact_path'] = target + physical.append(call) + target = 'artifacts/' + name + '/' + row['run_id'] + '.md' + shutil.copy2(shard / row['artifact_path'], args.output / target) + row['artifact_path'] = target + row['physical_artifact_path'] = physical[-1]['physical_artifact_path'] + row['supersedes_attempt_id'] = old['attempt_id'] + corrected.append(row) + print(name, row['ok'], row['output_chars'], flush=True) + if digest(args.dataset) != plan['source_sha256'] or digest(args.source / 'results.jsonl') != plan['parent_results_sha256']: + raise ValueError('Evidence changed during correction') + for name, binary in binaries.items(): + if digest(binary) != selected[name]['provider_metadata']['binary_sha256']: + raise ValueError('Binary changed during correction') + for filename, values in [('results.jsonl', corrected), ('physical-attempts.jsonl', physical)]: + (args.output / filename).write_text(''.join(json.dumps(r, ensure_ascii=False) + '\n' for r in values)) + write(args.output / 'certificate.json', {'kind': plan['kind'], 'preflight': plan, 'postflight': plan, + 'results_sha256': digest(args.output / 'results.jsonl'), + 'physical_sha256': digest(args.output / 'physical-attempts.jsonl')}) + + +if __name__ == '__main__': + main() diff --git a/scripts/check_webmainbench_full_report.py b/scripts/check_webmainbench_full_report.py new file mode 100644 index 000000000..781a7a8ba --- /dev/null +++ b/scripts/check_webmainbench_full_report.py @@ -0,0 +1,252 @@ +"""Release gate for the separately certified complete WebMainBench report.""" +from __future__ import annotations + +import argparse +from collections import Counter, defaultdict +import hashlib +import json +from pathlib import Path +import sys +from bs4 import BeautifulSoup + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from scripts.report_presentation_contract import check_presentation +from harness.web_fetch.safety import contained_path +from harness.web_fetch.report import ERROR_PAGE, WEBMAINBENCH_FULL_CAUSE_OWNERS + + +def check_result_payload(row: dict, index: dict, artifact_index: dict, shards: dict) -> None: + key = row["provider"] + "|" + row["case_id"] + shard_path = index.get(key) + if shard_path not in shards: + raise ValueError("Result shard missing: " + key) + bundled = shards[shard_path]["results"].get(key) + if not bundled or {name: value for name, value in bundled.items() if name not in ("diagnosis", "review")} != row: + raise ValueError("Result shard differs from scored evidence: " + key) + artifact = row["artifact_path"] + if artifact_index.get(artifact) != shard_path: + raise ValueError("Evidence target points to wrong shard: " + artifact) + digest = shards[shard_path]["artifacts"].get(artifact) + value = shards[shard_path]["blobs"].get(digest) + if not isinstance(value, str) or hashlib.sha256(value.encode()).hexdigest() != row["output_sha256"]: + raise ValueError("Raw output absent or changed: " + artifact) + + +def check_diagnosis_inventory(analysis: BeautifulSoup) -> None: + dialog = analysis.select_one('dialog#diagnosis-dialog') + required = ('#diagnosis-title', '#diagnosis-stage', '#diagnosis-error', + '#diagnosis-log', '#diagnosis-cause', '#diagnosis-treatment', + '#diagnosis-result', '#diagnosis-output', '#diagnosis-case', + '#diagnosis-close') + if not dialog or any(dialog.select_one(selector) is None for selector in required): + raise ValueError('Failure diagnosis dialog lacks error, review or evidence targets') + for row in analysis.select('tr[data-result]'): + if (row.get('data-kind') != '未触发诊断' + and (not row.get('data-case-id') or row.select_one('button[data-diagnose]') is None)): + raise ValueError('Diagnostic result row cannot open its in-page diagnosis') + + +def check(root: Path) -> None: + repo = Path(__file__).resolve().parents[1] + profile_path = repo / 'evaluation/webmainbench-full/current-report.json' + target = json.loads((repo / 'evaluation/webmainbench-full/publication-target.json').read_text(encoding='utf-8')) + fixed_profile_path = repo / 'evaluation/webmainbench-full/moli-fixed-report.json' + if (target.get('profile_sha256') != hashlib.sha256(profile_path.read_bytes()).hexdigest() + or target.get('moli_fixed_profile_sha256') != hashlib.sha256(fixed_profile_path.read_bytes()).hexdigest() + or not str(target.get('expected_remote_version', '')).startswith('ver_') + or not str(target.get('share_url', '')).startswith(str(target.get('host', '')).rstrip('/') + '/v/') + or not target.get('site_slug')): + raise ValueError('WebMainBench publication target is not bound to the certified profile') + six_binding = json.loads((root / 'six-method-binding.json').read_text(encoding='utf-8')) + fixed_profile = json.loads(fixed_profile_path.read_text(encoding='utf-8')) + if (six_binding.get('kind') != 'webmainbench_six_version_full_scope_binding' + or six_binding.get('logical_results') != 3270 + or six_binding.get('fixed_profile_sha256') != hashlib.sha256(fixed_profile_path.read_bytes()).hexdigest() + or six_binding.get('fixed_source_commit') != fixed_profile.get('source_commit') + or six_binding.get('results_sha256') != hashlib.sha256((root / 'original-results.jsonl').read_bytes()).hexdigest() + or six_binding.get('fixed_certificate_sha256') != hashlib.sha256((root / 'fixed-cohort-certificate.json').read_bytes()).hexdigest()): + raise ValueError('Six-version report binding missing or changed') + releases = json.loads((Path(__file__).resolve().parents[1] / 'research/report-changes.json').read_text(encoding='utf-8'))['releases'] + expected_releases = [item for item in releases if 'webmainbench_frozen_html_to_markdown' in item.get('comparison_kinds', [])] + history = BeautifulSoup((root / 'history/index.html').read_text(encoding='utf-8'), 'html.parser') + history_rows = history.select('tr[data-release-id]') + if ([row['data-release-id'] for row in history_rows] != [item['id'] for item in expected_releases] + or any(item['title'] not in row.get_text(' ', strip=True) or + item['summary'] not in row.get_text(' ', strip=True) + for row, item in zip(history_rows, expected_releases))): + raise ValueError('WebMainBench release history differs from structured records') + binding_path = Path(__file__).resolve().parents[1] / "evaluation/webmainbench-full/content-binding.json" + binding = json.loads(binding_path.read_text(encoding="utf-8")) + for path, expected in ((root / "report-content.json", binding["sha256"]),): + if hashlib.sha256(path.read_bytes()).hexdigest() != expected: + raise ValueError("Bound report asset changed: " + path.name) + failure_binding = json.loads((Path(__file__).resolve().parents[1] / "evaluation/webmainbench-full/failure-analysis-binding.json").read_text(encoding="utf-8")) + failure_bytes = (root / "failure-analysis.json").read_bytes() + if hashlib.sha256(failure_bytes).hexdigest() != failure_binding["sha256"]: + raise ValueError("Bound failure analysis changed") + failure_analysis = json.loads(failure_bytes) + manifest = json.loads((root / "report-manifest.json").read_text(encoding="utf-8")) + if manifest != {"schema_version": 1, "report": "webmainbench-545-full", + "languages": ["zh"], "case_count": 545, "provider_count": 6, + "result_count": 3270}: + raise ValueError("Incomplete WebMainBench report manifest") + dataset = json.loads((root / "dataset.json").read_text(encoding="utf-8")) + records = [json.loads(line) for line in (root / "results.jsonl").read_text(encoding="utf-8").splitlines()] + correction_binding = repo / 'evaluation/webmainbench-full/case-correction-binding.json' + if correction_binding.exists(): + receipt = json.loads((root / 'case-correction.json').read_text()) + if (receipt['preflight'] != receipt['postflight'] + or receipt['current_results_sha256'] != hashlib.sha256((root / 'results.jsonl').read_bytes()).hexdigest() + or receipt['score_summaries_sha256'] != hashlib.sha256((root / 'score-summaries.json').read_bytes()).hexdigest()): + raise ValueError('Published case correction differs from its certified results') + selected = [r for r in records if r['case_id'] == receipt['preflight']['case_id']] + if len(selected) != 6 or any(not r.get('supersedes_attempt_id') for r in selected): + raise ValueError('Case correction missing a method') + original_certificate = {key: receipt[key] for key in ('kind', 'preflight', 'postflight', 'results_sha256', 'physical_sha256')} + certificate_bytes = (json.dumps(original_certificate, ensure_ascii=False, indent=2) + '\n').encode() + if hashlib.sha256(certificate_bytes).hexdigest() != json.loads(correction_binding.read_text())['sha256']: + raise ValueError('Case correction certificate differs from repository binding') + teds = json.loads((root / 'teds-correction.json').read_text()) + if receipt['preflight']['parent_results_sha256'] != teds['output_results_sha256']: + raise ValueError('Case correction predecessor differs from certified TEDS evidence') + by_provider = {r['provider']: r for r in selected} + replacement_bytes = ''.join(json.dumps(by_provider[p], ensure_ascii=False) + '\n' + for p in receipt['preflight']['providers']).encode() + if hashlib.sha256(replacement_bytes).hexdigest() != receipt['results_sha256']: + raise ValueError('Corrected rows differ from certified calls') + providers = dataset["coverage_plan"]["static_html"] + case_ids = {row["id"] for row in dataset["cases"]} + if len(case_ids) != 545 or len(records) != 3270 or len(providers) != 6: + raise ValueError("Full matrix missing") + expected = {(provider, case_id) for provider in providers for case_id in case_ids} + if {(row["provider"], row["case_id"]) for row in records} != expected: + raise ValueError("Report matrix differs from evidence") + reviewed = {(row["provider"], row["case_id"]) for row in failure_analysis["reviewed_failures"]} + if (len(reviewed) != len(failure_analysis["reviewed_failures"]) + or any(not all(isinstance(row.get(key), str) and row[key].strip() + for key in ("category_label", "cause", "treatment", "status")) + for row in failure_analysis["reviewed_failures"])): + raise ValueError("Published failure analysis contains duplicate or empty reviews") + home = check_presentation((root / "index.html").read_text(encoding="utf-8")) + guide = check_presentation((root / "guide/index.html").read_text(encoding="utf-8")) + illustrations = repo / 'evaluation/webmainbench-full/illustrations.json' + if illustrations.exists(): + for asset in json.loads(illustrations.read_text())['assets']: + filename = 'assets/' + asset['name'] + if (hashlib.sha256(contained_path(root, filename).read_bytes()).hexdigest() != asset['sha256'] + or not guide.select_one('img[src="' + filename + '"]')): + raise ValueError('Bound error analysis illustration missing or changed') + if correction_binding.exists() and not guide.select_one('#fixture-correction'): + raise ValueError('Corrected case explanation missing') + for page in (home, guide): + if len(page.select('td[data-metric-direction="max"]')) != 36: + raise ValueError("Core metrics or winner controls missing") + if "545" not in page.get_text(" ", strip=True): + raise ValueError("Complete population missing") + if len(home.select('td[data-metric-direction="min"]')) != 35: + raise ValueError("All-call or conditional latency comparison missing") + first_case = dataset["cases"][0]["id"] + example_targets = {"viewer/index.html?path=sources/" + first_case + ".html.txt", + "viewer/index.html?path=references/" + first_case + ".md", + "cases/index.html?id=" + first_case} + if not example_targets.issubset({link.get("href") for link in guide.select("a[href]")}): + raise ValueError("Interpretation example lacks source, reference or same-case result") + analysis = BeautifulSoup((root / "analysis/index.html").read_text(encoding="utf-8"), "html.parser") + check_diagnosis_inventory(analysis) + if (len(analysis.select("tr[data-result]")) != 3270 or not analysis.select_one("#kind") + or not analysis.select_one("#cause")): + raise ValueError("Result inventory incomplete") + if not any(row.get("data-kind") != "未触发诊断" for row in analysis.select("tr[data-result]")): + raise ValueError("Default diagnostic population is empty") + if not (root / "en/index.html").is_file() or "返回中文报告" not in (root / "en/index.html").read_text(encoding="utf-8"): + raise ValueError("English not-generated route missing") + case_template = BeautifulSoup((root / "cases/index.html").read_text(encoding="utf-8"), "html.parser") + if (not case_template.select_one("#case-results") or not case_template.select_one("#case-source") + or not case_template.select_one("#case-reference") or not case_template.select_one("#case-comparison")): + raise ValueError("Same-case comparison template is incomplete") + result_template = check_presentation((root / "results/index.html").read_text(encoding="utf-8")) + if not result_template.select_one('[data-result-summary]') or not result_template.select_one('#result-logs pre[data-kind="error"]'): + raise ValueError("Shared result template lacks summary or exact error log") + index = json.loads((root / "result-shard-index.json").read_text(encoding="utf-8")) + artifact_index = json.loads((root / "artifact-shard-index.json").read_text(encoding="utf-8")) + shard_paths = set(index.values()) | set(artifact_index.values()) + shards = {path: json.loads(contained_path(root, path).read_text(encoding="utf-8")) for path in shard_paths} + if len(index) != len(records) or len(shards) > 56: + raise ValueError("Complete result shard index missing or oversized") + failures = set() + diagnostic_counts = defaultdict(Counter) + for row in records: + shard = shards[index[row["provider"] + "|" + row["case_id"]]] + diagnosis = shard["results"][row["provider"] + "|" + row["case_id"]]["diagnosis"] + diagnostic_counts[row["provider"]][diagnosis] += 1 + path = row["artifact_path"] + output = shard["blobs"].get(shard["artifacts"].get(path), "") + if not row["ok"] or not row["output_chars"] or ERROR_PAGE.search(output): + failures.add((row["provider"], row["case_id"])) + if reviewed != {item for item in failures if item[0] != 'moli-fixed'}: + raise ValueError("Published baseline failure analysis scope is incomplete") + if not any(row['provider'] == 'moli-fixed' for row in records): + raise ValueError('Fixed Moli is absent from the result inventory') + cells = guide.select("td[data-diagnostic-provider][data-kind]") + expected_cells = {(provider, kind) for provider in providers + for kind in ("请求失败", "空输出", "错误页", "低全文相似度")} + if ({(cell["data-diagnostic-provider"], cell["data-kind"]) for cell in cells} != expected_cells + or len(cells) != len(expected_cells) or any( + int(cell.get_text(strip=True)) != diagnostic_counts[cell["data-diagnostic-provider"]][cell["data-kind"]] + for cell in cells + )): + raise ValueError("Interpretation diagnosis matrix differs from measured results") + cause_counts = Counter((entry["category_label"], entry["provider"]) + for entry in failure_analysis["reviewed_failures"]) + cause_cells = guide.select("td[data-cause-provider][data-cause-kind]") + expected_cause_cells = {(kind, provider) for kind in WEBMAINBENCH_FULL_CAUSE_OWNERS for provider in providers} + if ({(cell["data-cause-kind"], cell["data-cause-provider"]) for cell in cause_cells} != expected_cause_cells + or len(cause_cells) != len(expected_cause_cells) + or any(cell.get_text(strip=True) != + (str(cause_counts[cell["data-cause-kind"], cell["data-cause-provider"]]) + if cause_counts[cell["data-cause-kind"], cell["data-cause-provider"]] else "—") + for cell in cause_cells)): + raise ValueError("Per-method cause matrix differs from reviewed evidence") + reviewed_by_key = {(row["provider"], row["case_id"]): row for row in failure_analysis["reviewed_failures"]} + for row in records: + check_result_payload(row, index, artifact_index, shards) + bundled = shards[index[row["provider"] + "|" + row["case_id"]]]["results"][row["provider"] + "|" + row["case_id"]] + if bundled.get("review") != reviewed_by_key.get((row["provider"], row["case_id"])): + raise ValueError("Result detail differs from bound failure analysis: " + row["attempt_id"]) + for case in dataset["cases"]: + case_id = case["id"] + for path, expected_hash in (("sources/" + case_id + ".html.txt", case["input_html_sha256"]), + ("references/" + case_id + ".md", case["reference_sha256"])): + shard_path = artifact_index.get(path) + if shard_path not in shards: + raise ValueError("Frozen task evidence shard missing: " + path) + blob_hash = shards[shard_path]["artifacts"].get(path) + content = shards[shard_path]["blobs"].get(blob_hash) + if not isinstance(content, str) or hashlib.sha256(content.encode()).hexdigest() != expected_hash: + raise ValueError("Frozen task evidence changed: " + path) + physical = [json.loads(line) for line in (root / "physical-attempts.jsonl").read_text(encoding="utf-8").splitlines() if line.strip()] + for attempt in physical: + path = attempt["physical_artifact_path"] + shard_path = artifact_index.get(path) + if shard_path not in shards: + raise ValueError("Physical output shard missing: " + path) + digest = shards[shard_path]["artifacts"].get(path) + content = shards[shard_path]["blobs"].get(digest) + if not isinstance(content, str) or hashlib.sha256(content.encode()).hexdigest() != attempt["output_sha256"]: + raise ValueError("Physical output absent or changed: " + path) + if attempt not in shards[shard_path]["physical"].get(attempt["track_id"], []): + raise ValueError("Physical call missing from result detail: " + attempt["attempt_id"]) + public_files = [path for path in root.rglob("*") if path.is_file()] + if len(public_files) > 2000 or sum(path.stat().st_size for path in public_files) > 314572800: + raise ValueError("Report package exceeds Artifact Site deployment limits") + viewer = (root / "viewer/index.html").read_text(encoding="utf-8") + if "id=\"evidence-content\"" not in viewer or not (root / "assets/report.js").is_file(): + raise ValueError("On-demand evidence viewer missing") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("report", type=Path) + check(parser.parse_args().report) + print("Complete WebMainBench report release gate passed") diff --git a/scripts/correct_webmainbench_teds.py b/scripts/correct_webmainbench_teds.py index 0047ff157..226a4a44a 100644 --- a/scripts/correct_webmainbench_teds.py +++ b/scripts/correct_webmainbench_teds.py @@ -80,9 +80,9 @@ def main(): raise ValueError('Refusing symlinked correction evidence') expected = {(provider, case['id']) for provider in dataset['coverage_plan']['static_html'] for case in dataset['cases']} - if len(records) != len(expected) or len(expected) != 200 * len(summaries) or { + if len(records) != len(expected) or len(expected) != len(dataset['cases']) * len(summaries) or { (row['provider'], row['case_id']) for row in records} != expected: - raise ValueError(f'Correction requires the complete declared 200-by-{len(summaries)} matrix') + raise ValueError(f'Correction requires the complete declared {len(dataset["cases"])}-by-{len(summaries)} matrix') # Verify the exact official implementation, not only its Git HEAD. for summary in summaries.values(): for relative, expected in summary['scorer']['metric_source_sha256'].items(): @@ -175,7 +175,7 @@ def category(row): 'input_results_sha256': digest(source / 'results.jsonl'), 'output_results_sha256': digest(results_path), 'dataset_sha256': digest(source / 'dataset.json'), - 'sample_manifest_sha256': dataset['sample_manifest_sha256'], + 'sample_manifest_sha256': dataset.get('sample_manifest_sha256'), 'inherited_correction_receipt_sha256': inherited_receipt_sha256, 'inherited_logical_results': len(inherited), 'source_code_sha256': {str(path.relative_to(ROOT)): digest(path) for path in source_files}, diff --git a/scripts/headless_worker.py b/scripts/headless_worker.py index e0082b5c2..5ba549a75 100644 --- a/scripts/headless_worker.py +++ b/scripts/headless_worker.py @@ -75,6 +75,7 @@ def fetch(request): plan, allow_private_network=bool(request.get('allow_private_network')), ca_cert=request.get('ca_cert'), + http_proxy=request.get('http_proxy'), ) environment = None if engine == 'obscura' and request.get('ca_cert'): @@ -129,8 +130,11 @@ def fetch(request): if engine == 'kitesurf': browser = p.chromium.connect_over_cdp(request['endpoint'], timeout=timeout*1000) else: - browser = p.chromium.launch(executable_path=request['binary'], headless=True, - args=request.get('browser_args') or [], timeout=timeout*1000) + launch = dict(executable_path=request['binary'], headless=True, + args=request.get('browser_args') or [], timeout=timeout*1000) + if request.get('http_proxy'): + launch['proxy'] = {'server': request['http_proxy']} + browser = p.chromium.launch(**launch) try: meta['browser_version'] = browser.version # The benchmark fixture has a per-run CA, not a system-trusted diff --git a/scripts/report_presentation_contract.py b/scripts/report_presentation_contract.py index dd66b7641..46580eee0 100644 --- a/scripts/report_presentation_contract.py +++ b/scripts/report_presentation_contract.py @@ -10,8 +10,12 @@ def check_presentation(html): groups = defaultdict(list) for cell in table.select('td[data-metric-direction]'): direction = cell['data-metric-direction'] - if direction not in ('min', 'max') or not cell.get('data-metric'): + if direction not in ('min', 'max', 'neutral') or not cell.get('data-metric'): raise ValueError('Numeric comparison lacks metric identity or direction') + if direction == 'neutral': + if cell.select_one('.comparison-winner'): + raise ValueError('Out-of-cohort value cannot be highlighted') + continue value = cell.get('data-sort') if value is None: if cell.select_one('.comparison-winner'): diff --git a/scripts/review_webmainbench_teds_correction.py b/scripts/review_webmainbench_teds_correction.py index 55fba3e02..a0c422be5 100644 --- a/scripts/review_webmainbench_teds_correction.py +++ b/scripts/review_webmainbench_teds_correction.py @@ -40,6 +40,12 @@ def review(source, corrected): if len({row['attempt_id'] for row in after}) != len(after): raise ValueError('Duplicate corrected attempt') for name in ('dataset.json', 'sample-manifest.json', 'physical-attempts.jsonl', 'execution.json', 'result-review.json'): + source_exists = (source / name).exists() + corrected_exists = (corrected / name).exists() + if not source_exists and not corrected_exists: + continue + if source_exists != corrected_exists: + raise ValueError('Correction changed evidence file presence: ' + name) if digest(source / name) != digest(corrected / name): raise ValueError('Unexpected change: ' + name) changed = [] @@ -76,7 +82,7 @@ def review(source, corrected): impacts = {} for provider, summary in summaries.items(): records = [row for row in after if row['provider'] == provider] - if len(records) != 200: + if len(records) != len(json.loads((source / 'dataset.json').read_text())['cases']): raise ValueError('Provider scope mismatch') current = summary['official_aggregate'] for name in ('text_edit', 'code_edit', 'formula_edit', 'table_edit', 'table_TEDS'): diff --git a/scripts/run_certified_benchmark.py b/scripts/run_certified_benchmark.py index 348cf7d0e..9a08d4d81 100644 --- a/scripts/run_certified_benchmark.py +++ b/scripts/run_certified_benchmark.py @@ -104,6 +104,10 @@ def run(command: list[str]) -> None: def main(argv: list[str] | None = None) -> None: + if (argv or sys.argv[1:])[:1] == ["webmainbench-correction"]: + from scripts.certify_webmainbench_case_correction import main as correction_main + correction_main((argv or sys.argv[1:])[1:]) + return if (argv or sys.argv[1:])[:1] == ["wcxb"]: run_wcxb((argv or sys.argv[1:])[1:]) return diff --git a/scripts/run_certified_webmainbench_full.py b/scripts/run_certified_webmainbench_full.py new file mode 100644 index 000000000..44be5bc26 --- /dev/null +++ b/scripts/run_certified_webmainbench_full.py @@ -0,0 +1,156 @@ +"""Run the predeclared complete WebMainBench cohort with source and result checks.""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from scripts.run_webmainbench_benchmark import load_source +from harness.web_fetch.config import load_runtime_env + + +def digest(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def read_jsonl(path: Path) -> list[dict]: + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()] + + +def source_binding(source: Path, scorer: Path, profile: dict) -> dict: + if digest(source) != profile["source_dataset_sha256"]: + raise ValueError("WebMainBench source bytes changed") + records = load_source(source) + case_ids = [record["track_id"] for record in records] + case_digest = hashlib.sha256(("\n".join(case_ids) + "\n").encode()).hexdigest() + if len(records) != profile["case_count"] or case_digest != profile["case_ids_sha256"]: + raise ValueError("WebMainBench case inventory changed") + commit = subprocess.check_output(["git", "-C", str(scorer), "rev-parse", "HEAD"], text=True).strip() + if commit != profile["official_scorer_commit"]: + raise ValueError("Official scorer commit changed") + return {"source_sha256": digest(source), "case_ids_sha256": case_digest, + "scorer_commit": commit, "case_count": len(records)} + + +def verify_declared_binary(profile: dict) -> None: + if not profile.get('binary_sha256'): + return + load_runtime_env() + binary = shutil.which(os.getenv('MOLI_BINARY') or 'moli') + if binary is None or digest(Path(binary)) != profile['binary_sha256']: + raise ValueError('Declared Moli binary differs before provider calls') + + +def verify_source_checkout(profile: dict, checkout: Path | None) -> None: + source_commit = profile.get('source_commit') + if not source_commit: + return + if checkout is None: + raise ValueError('Candidate binary source checkout is required') + actual_commit = subprocess.check_output( + ['git', '-C', str(checkout), 'rev-parse', 'HEAD'], text=True).strip() + dirty = subprocess.check_output( + ['git', '-C', str(checkout), 'status', '--porcelain'], text=True).strip() + if actual_commit != source_commit or dirty: + raise ValueError('Candidate binary source differs from declared clean commit') + + +def verify_shard(shard: Path, provider: str, case_ids: set[str], profile: dict) -> dict: + rows = read_jsonl(shard / "results.jsonl") + physical = read_jsonl(shard / "physical-attempts.jsonl") + execution = json.loads((shard / "execution.json").read_text(encoding="utf-8")) + dataset = json.loads((shard / "dataset.json").read_text(encoding="utf-8")) + if (len(rows) != profile["case_count"] or {row["track_id"] for row in rows} != case_ids + or len({row["track_id"] for row in rows}) != len(rows) + or dataset["case_count"] != profile["case_count"] + or set(dataset["case_ids"]) != case_ids + or execution["case_count"] != profile["case_count"] + or execution["provider"] != provider + or execution["source_dataset_sha256"] != profile["source_dataset_sha256"]): + raise ValueError(provider + ": incomplete or mismatched cohort") + attempt_ids = {row["attempt_id"] for row in physical} + if len(attempt_ids) != len(physical) or sum(row["physical_attempt_count"] for row in rows) != len(physical): + raise ValueError(provider + ": physical call inventory mismatch") + for row in rows: + path = shard / row["artifact_path"] + if path.is_symlink() or digest(path) != row["output_sha256"]: + raise ValueError(provider + ": result artifact mismatch: " + row["track_id"]) + for row in physical: + path = shard / row["physical_artifact_path"] + if path.is_symlink() or digest(path) != row["output_sha256"]: + raise ValueError(provider + ": physical artifact mismatch: " + row["attempt_id"]) + return {"provider": provider, "logical_results": len(rows), "physical_calls": len(physical), + "request_successes": sum(bool(row["ok"]) for row in rows), + "results_sha256": digest(shard / "results.jsonl"), + "physical_attempts_sha256": digest(shard / "physical-attempts.jsonl"), + "execution_sha256": digest(shard / "execution.json")} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-root", type=Path, required=True) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--scorer", type=Path, required=True) + parser.add_argument("--profile", type=Path, default=ROOT / "evaluation/webmainbench-full/current-report.json") + parser.add_argument("--binary-source", type=Path, + help="Exact clean source checkout used to build a profile-bound candidate binary") + args = parser.parse_args() + if args.output_root.exists(): + parser.error("Output root already exists; cohort evidence is immutable") + profile = json.loads(args.profile.read_text(encoding="utf-8")) + declared = {"webmainbench-545-full-five-methods": ["moli", "lightpanda", "obscura", "chromium", "kitesurf"], + "webmainbench-545-moli-fixed": ["moli"]} + if (profile.get("providers") != declared.get(profile.get("dataset_id")) + or profile.get("case_count") != 545 + or profile.get("runs_per_case") != 1 or profile.get("concurrency") != 1): + parser.error("Not the complete declared WebMainBench method matrix") + profile_sha = digest(args.profile) + before = source_binding(args.source, args.scorer, profile) + source_commit = profile.get('source_commit') + verify_source_checkout(profile, args.binary_source) + verify_declared_binary(profile) + case_ids = {record["track_id"] for record in load_source(args.source)} + args.output_root.mkdir(parents=True) + (args.output_root / "preflight.json").write_text( + json.dumps({"profile_sha256": profile_sha, **before}, indent=2) + "\n", encoding="utf-8") + summaries = [] + for provider in profile["providers"]: + print("Starting certified full-cohort method: " + provider, flush=True) + command = [sys.executable, str(ROOT / "scripts/run_webmainbench_benchmark.py"), + "--provider", provider, "--dataset", str(args.source), + "--output", str(args.output_root / provider), + "--timeout", str(profile["timeout_seconds"]), + "--max-failure-retries", str(profile["max_failure_retries"])] + if provider != "kitesurf": + version = profile["versions"][provider].split()[-1] + command += ["--expected-version", version] + subprocess.run(command, cwd=ROOT, check=True) + summaries.append(verify_shard(args.output_root / provider, provider, case_ids, profile)) + expected_binary = profile.get("binary_sha256") + if expected_binary and json.loads((args.output_root / provider / "execution.json").read_text())[ + "implementation_identity"]["binary_sha256"] != expected_binary: + raise ValueError("Measured binary differs from declared version") + if source_binding(args.source, args.scorer, profile) != before or digest(args.profile) != profile_sha: + raise ValueError("Source, scorer or profile changed during provider calls") + verify_source_checkout(profile, args.binary_source) + certificate = {"schema_version": 1, "kind": "certified_webmainbench_545_full_v1", + "profile_sha256": profile_sha, "preflight": before, + "postflight": source_binding(args.source, args.scorer, profile), + "binary_source_commit": source_commit, + "measured_binary_sha256": profile.get('binary_sha256'), + "providers": summaries} + (args.output_root / "cohort-certificate.json").write_text( + json.dumps(certificate, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print("Certified complete WebMainBench cohort: " + str(args.output_root), flush=True) + + +if __name__ == "__main__": + main() diff --git a/scripts/run_webmainbench_benchmark.py b/scripts/run_webmainbench_benchmark.py index ba0728cb5..3f7b2fdc0 100644 --- a/scripts/run_webmainbench_benchmark.py +++ b/scripts/run_webmainbench_benchmark.py @@ -7,7 +7,9 @@ import json import os import platform +import select import shutil +import socket import ssl import subprocess import sys @@ -154,27 +156,56 @@ def case_for(record: dict, url: str) -> EvalCase: @contextmanager -def fixture_server(records: list[dict]): - bodies = { - "/case/" + record["track_id"]: record["html"].encode("utf-8") - for record in records - } +def fixture_server(records: list[dict], preserve_original_origin: bool = False): + bodies = {} + path_fallbacks = {} + ambiguous_paths = set() + hosts = set() + for record in records: + if not preserve_original_origin: + bodies["/case/" + record["track_id"]] = record["html"].encode("utf-8") + continue + parsed = urlsplit(record["url"]) + host = parsed.hostname + if not host: + raise ValueError("Frozen record URL lacks a hostname: " + record["track_id"]) + hosts.add(host) + path_key = (host.casefold(), parsed.path or "/") + key = (*path_key, parsed.query) + if key in bodies: + raise ValueError("Frozen fixture URL is not unique: " + record["url"]) + bodies[key] = record["html"].encode("utf-8") + if path_key in path_fallbacks: + ambiguous_paths.add(path_key) + else: + path_fallbacks[path_key] = bodies[key] + + def frozen_body(host_header: str, request_path: str): + host = host_header.rsplit(":", 1)[0].strip("[]").casefold() + parsed = urlsplit(request_path) + path_key = (host, parsed.path or "/") + exact = bodies.get((*path_key, parsed.query)) + if exact is not None: + return exact + return None if path_key in ambiguous_paths else path_fallbacks.get(path_key) + + def write_frozen(handler, body): + if body is None: + handler.send_error(404) + return + handler.send_response(200) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.send_header("Content-Security-Policy", CSP) + handler.send_header("Cache-Control", "no-store") + handler.end_headers() + handler.wfile.write(body) class Handler(BaseHTTPRequestHandler): def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler API - # Frozen pages may add a query string during inline navigation. - # It does not change which frozen case supplies the response. - body = bodies.get(urlsplit(self.path).path) - if body is None: - self.send_error(404) - return - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.send_header("Content-Security-Policy", CSP) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(body) + body = (frozen_body(self.headers.get("Host", ""), self.path) + if preserve_original_origin else bodies.get(urlsplit(self.path).path)) + write_frozen(self, body) def log_message(self, _format, *_args): return @@ -186,13 +217,16 @@ def log_message(self, _format, *_args): tls = Path(directory) ca_key, ca_cert = tls / "ca-key.pem", tls / "ca.pem" leaf_key, leaf_csr, leaf_cert = tls / "leaf-key.pem", tls / "leaf.csr", tls / "leaf.pem" + san = (",".join("IP:" + host if host.replace(".", "").isdigit() else "DNS:" + host + for host in sorted(hosts)) if preserve_original_origin + else "DNS:localhost,IP:127.0.0.1") commands = [ [openssl, "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", str(ca_key), "-out", str(ca_cert), "-days", "1", "-subj", "/CN=WebMainBench-Local-CA", "-addext", "basicConstraints=critical,CA:TRUE"], [openssl, "req", "-newkey", "rsa:2048", "-nodes", "-keyout", str(leaf_key), - "-out", str(leaf_csr), "-subj", "/CN=localhost", - "-addext", "subjectAltName=DNS:localhost,IP:127.0.0.1"], + "-out", str(leaf_csr), "-subj", "/CN=WebMainBench-Frozen-Origin", + "-addext", "subjectAltName=" + san], [openssl, "x509", "-req", "-in", str(leaf_csr), "-CA", str(ca_cert), "-CAkey", str(ca_key), "-CAcreateserial", "-out", str(leaf_cert), "-days", "1", "-copy_extensions", "copy"], @@ -207,9 +241,47 @@ def log_message(self, _format, *_args): server.socket = context.wrap_socket(server.socket, server_side=True) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() + + class ProxyHandler(BaseHTTPRequestHandler): + def do_CONNECT(self): # noqa: N802 - BaseHTTPRequestHandler API + upstream = socket.create_connection(("127.0.0.1", server.server_port), timeout=5) + self.send_response(200, "Connection Established") + self.end_headers() + peers = (self.connection, upstream) + try: + while True: + ready, _, _ = select.select(peers, (), (), 5) + for source in ready: + data = source.recv(65536) + if not data: + return + target = upstream if source is self.connection else self.connection + target.sendall(data) + finally: + upstream.close() + + def do_GET(self): # noqa: N802 - BaseHTTPRequestHandler API + parsed = urlsplit(self.path) + request_path = parsed.path + (("?" + parsed.query) if parsed.query else "") + write_frozen(self, frozen_body(parsed.netloc, request_path)) + + def log_message(self, _format, *_args): + return + + proxy = proxy_thread = None + if preserve_original_origin: + proxy = ThreadingHTTPServer(("127.0.0.1", 0), ProxyHandler) + proxy_thread = threading.Thread(target=proxy.serve_forever, daemon=True) + proxy_thread.start() try: - yield f"https://localhost:{server.server_port}", ca_cert + endpoint = (f"http://127.0.0.1:{proxy.server_port}" if proxy + else f"https://localhost:{server.server_port}") + yield endpoint, ca_cert finally: + if proxy and proxy_thread: + proxy.shutdown() + proxy.server_close() + proxy_thread.join(timeout=5) server.shutdown() server.server_close() thread.join(timeout=5) @@ -240,27 +312,49 @@ def public_metadata(value: dict) -> dict: return {key: item for key, item in value.items() if not key.startswith("_")} -def verify_loopback_sandbox(fixture_base: str, ca_cert: Path, record: dict) -> dict: +def verify_loopback_sandbox(endpoint: str, ca_cert: Path, record: dict, + preserve_original_origin: bool = False) -> dict: """Prove that the measured child can read the fixture but cannot connect externally.""" - parsed = urlsplit(fixture_base) - path = "/case/" + record["track_id"] - probe = ( + parsed = urlsplit(endpoint) + origin = urlsplit(record["url"]) + path = origin.path or "/" if preserve_original_origin else "/case/" + record["track_id"] + direct_probe = ( "import errno,hashlib,json,socket,ssl,sys;" "host,port,path,expected,ca=sys.argv[1],int(sys.argv[2]),sys.argv[3],sys.argv[4],sys.argv[5];" "s=ssl.create_default_context(cafile=ca).wrap_socket(socket.create_connection((host,port),2),server_hostname=host);" "s.sendall(('GET '+path+' HTTP/1.0\\r\\nHost: '+host+'\\r\\n\\r\\n').encode());" - "raw=b'';" - "\nwhile True:\n chunk=s.recv(65536)\n if not chunk: break\n raw+=chunk\n" - "s.close();body=raw.split(b'\\r\\n\\r\\n',1)[1];" + "response=b'';" + "\nwhile True:\n chunk=s.recv(65536)\n if not chunk: break\n response+=chunk\n" + "s.close();body=response.split(b'\\r\\n\\r\\n',1)[1];" + "result={'loopback_body_sha256':hashlib.sha256(body).hexdigest()};" + "\ntry:\n socket.create_connection(('1.1.1.1',443),1);result['external_connect_errno']=0\n" + "except OSError as exc:\n result['external_connect_errno']=exc.errno\n" + "print(json.dumps(result));" + "sys.exit(0 if result['loopback_body_sha256']==expected and result['external_connect_errno'] in (errno.EPERM,errno.EACCES) else 9)" + ) + proxy_probe = ( + "import errno,hashlib,json,socket,ssl,sys;" + "proxy,port,host,path,expected,ca=sys.argv[1],int(sys.argv[2]),sys.argv[3],sys.argv[4],sys.argv[5],sys.argv[6];" + "raw=socket.create_connection((proxy,port),2);" + "raw.sendall(('CONNECT '+host+':443 HTTP/1.0\\r\\nHost: '+host+'\\r\\n\\r\\n').encode());" + "head=b'';" + "\nwhile b'\\r\\n\\r\\n' not in head:\n head+=raw.recv(4096)\n" + "s=ssl.create_default_context(cafile=ca).wrap_socket(raw,server_hostname=host);" + "s.sendall(('GET '+path+' HTTP/1.0\\r\\nHost: '+host+'\\r\\n\\r\\n').encode());" + "response=b'';" + "\nwhile True:\n chunk=s.recv(65536)\n if not chunk: break\n response+=chunk\n" + "s.close();body=response.split(b'\\r\\n\\r\\n',1)[1];" "result={'loopback_body_sha256':hashlib.sha256(body).hexdigest()};" "\ntry:\n socket.create_connection(('1.1.1.1',443),1);result['external_connect_errno']=0\n" "except OSError as exc:\n result['external_connect_errno']=exc.errno\n" "print(json.dumps(result));" "sys.exit(0 if result['loopback_body_sha256']==expected and result['external_connect_errno'] in (errno.EPERM,errno.EACCES) else 9)" ) + probe = proxy_probe if preserve_original_origin else direct_probe + probe_args = [origin.hostname or "", path] if preserve_original_origin else [path] completed = subprocess.run( ["/usr/bin/sandbox-exec", "-p", LOOPBACK_SANDBOX_PROFILE, sys.executable, "-c", probe, - parsed.hostname or "127.0.0.1", str(parsed.port), path, + parsed.hostname or "127.0.0.1", str(parsed.port), *probe_args, sha256_bytes(record["html"].encode("utf-8")), str(ca_cert)], capture_output=True, text=True, timeout=10, check=False, ) @@ -305,7 +399,9 @@ def run(args: argparse.Namespace) -> None: else: records = load_source(args.dataset, selected) source_digest = DATASET_SHA256 - expected_versions = EXPECTED_VERSIONS + expected_versions = {**EXPECTED_VERSIONS} + if args.expected_version: + expected_versions[args.provider] = args.expected_version dataset_id = "webmainbench-545" if args.limit: records = records[: args.limit] @@ -339,6 +435,8 @@ def run(args: argparse.Namespace) -> None: "selection_manifest": selection_binding, "input_contract": { "body": "official html UTF-8 bytes without mutation", + "origin": ("original URL retained while frozen bytes are served by a loopback-only proxy" + if args.preserve_original_origin else "original URL retained as evidence metadata only"), "subresources": "blocked", "javascript": "inline page-authored JavaScript may run; external script and data access is blocked", "network_policy": "local engines run under a loopback-only OS sandbox; Kitesurf rejects subresource types", @@ -360,14 +458,17 @@ def run(args: argparse.Namespace) -> None: network_isolation_probe = None fixture_ca_artifact = None - with fixture_server(records) as (fixture_base, ca_cert), physical_path.open("w", encoding="utf-8") as physical, result_path.open("w", encoding="utf-8") as results: + with fixture_server(records, args.preserve_original_origin) as (fixture_endpoint, ca_cert), physical_path.open("w", encoding="utf-8") as physical, result_path.open("w", encoding="utf-8") as results: if args.provider == "kitesurf": network_isolation_probe = {"mode": "cloudflare_reject_external_resource_types"} else: provider.config["ca_cert"] = str(ca_cert) + if args.preserve_original_origin: + provider.config["http_proxy"] = fixture_endpoint shutil.copy2(ca_cert, args.output / "fixture-ca.pem") fixture_ca_artifact = "fixture-ca.pem" - network_isolation_probe = verify_loopback_sandbox(fixture_base, ca_cert, records[0]) + network_isolation_probe = verify_loopback_sandbox( + fixture_endpoint, ca_cert, records[0], args.preserve_original_origin) network_isolation_probe["fixture_ca_sha256"] = sha256_bytes(ca_cert.read_bytes()) for index, record in enumerate(records, 1): body = record["html"].encode("utf-8") @@ -375,7 +476,8 @@ def run(args: argparse.Namespace) -> None: attempts = [] total_elapsed = 0.0 for retry in range(args.max_failure_retries + 1): - target_url = f"{fixture_base}/case/{record['track_id']}" + target_url = (record["url"] if args.preserve_original_origin + else f"{fixture_endpoint}/case/{record['track_id']}") call_started = iso_now() clock = time.monotonic() response = fetch_record(provider, cloudflare, record, target_url, args.timeout) @@ -461,6 +563,11 @@ def main() -> None: parser.add_argument("--limit", type=int) parser.add_argument("--timeout", type=float, default=45.0) parser.add_argument("--max-failure-retries", type=int, choices=(0, 1), default=1) + parser.add_argument("--expected-version", help="Exact release version required for this provider") + parser.add_argument( + "--preserve-original-origin", action="store_true", + help="Serve frozen bytes through a loopback proxy while retaining each source URL", + ) args = parser.parse_args() if args.benchmark == 'wcxb' and args.provider not in WCXB_PROVIDERS: parser.error(f'--provider {args.provider} is not in the WCXB method matrix') diff --git a/scripts/score_certified_webmainbench_full.py b/scripts/score_certified_webmainbench_full.py new file mode 100644 index 000000000..940d4b452 --- /dev/null +++ b/scripts/score_certified_webmainbench_full.py @@ -0,0 +1,91 @@ +"""Score every provider in the certified complete WebMainBench cohort.""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from scripts.run_certified_webmainbench_full import digest, read_jsonl, source_binding +from scripts.score_webmainbench_results import aggregate + + +def verify_score(shard: Path, provider: str, case_ids: set[str], scorer: Path, profile: dict) -> dict: + scored = read_jsonl(shard / "scored-results.jsonl") + measured = read_jsonl(shard / "results.jsonl") + summary = json.loads((shard / "score-summary.json").read_text(encoding="utf-8")) + if (len(scored) != profile["case_count"] or len(measured) != len(scored) + or {row["track_id"] for row in scored} != case_ids + or len({row["track_id"] for row in scored}) != len(scored) + or any(row["provider"] != provider for row in scored) + or [{key: value for key, value in row.items() if key != "metrics"} for row in scored] != measured + or summary.get("provider") != provider or summary.get("case_count") != len(scored) + or summary.get("scorer", {}).get("commit") != profile["official_scorer_commit"] + or summary.get("scorer", {}).get("metric_config") != profile["metric_config"] + or summary["official_aggregate"] != aggregate(scored)): + raise ValueError(provider + ": scored evidence is incomplete or differs from measured calls") + for relative, expected in summary["scorer"]["metric_source_sha256"].items(): + if digest(scorer / relative) != expected: + raise ValueError(provider + ": scorer source changed: " + relative) + return {"provider": provider, "scored_results_sha256": digest(shard / "scored-results.jsonl"), + "score_summary_sha256": digest(shard / "score-summary.json"), + "logical_results": len(scored)} + + +def scorer_source_binding(scorer: Path) -> dict[str, str]: + files = sorted((scorer / "webmainbench/metrics").glob("*.py")) + if not files: + raise ValueError("Pinned scorer metrics source is absent") + return {str(path.relative_to(scorer)): digest(path) for path in files} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--cohort", type=Path, required=True) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--scorer", type=Path, required=True) + parser.add_argument("--profile", type=Path, default=ROOT / "evaluation/webmainbench-full/current-report.json") + args = parser.parse_args() + if (args.cohort / "scoring-certificate.json").exists(): + parser.error("Scoring certificate already exists; refusing overwrite") + profile = json.loads(args.profile.read_text(encoding="utf-8")) + cohort = json.loads((args.cohort / "cohort-certificate.json").read_text(encoding="utf-8")) + if (cohort.get("kind") != "certified_webmainbench_545_full_v1" + or cohort.get("profile_sha256") != digest(args.profile)): + parser.error("Complete certified cohort is required") + before = source_binding(args.source, args.scorer, profile) + scorer_files = scorer_source_binding(args.scorer) + if before != cohort["postflight"]: + parser.error("Scoring input differs from the certified cohort") + source_rows = read_jsonl(args.source) + case_ids = {row["track_id"] for row in source_rows} + if len(source_rows) != profile["case_count"] or len(case_ids) != len(source_rows): + parser.error("Source case inventory differs from the declared cohort") + receipts = [] + for provider in profile["providers"]: + shard = args.cohort / provider + print("Scoring " + provider, flush=True) + with (shard / "scoring.log").open("w", encoding="utf-8") as stream: + subprocess.run([sys.executable, str(ROOT / "scripts/score_webmainbench_results.py"), + "--evidence", str(shard), "--dataset", str(args.source), + "--scorer-repo", str(args.scorer)], cwd=ROOT, stdout=stream, + stderr=subprocess.STDOUT, check=True) + receipts.append(verify_score(shard, provider, case_ids, args.scorer, profile)) + if source_binding(args.source, args.scorer, profile) != before or scorer_source_binding(args.scorer) != scorer_files: + raise ValueError("Source or scorer changed during scoring") + print(provider + " scored and verified", flush=True) + certificate = {"schema_version": 1, "kind": "certified_webmainbench_scoring_v1", + "cohort_certificate_sha256": digest(args.cohort / "cohort-certificate.json"), + "source_binding": before, "scorer_source_sha256": scorer_files, + "providers": receipts} + (args.cohort / "scoring-certificate.json").write_text( + json.dumps(certificate, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print("Certified complete scoring", flush=True) + + +if __name__ == "__main__": + main() diff --git a/tests/test_certified_webmainbench_full.py b/tests/test_certified_webmainbench_full.py new file mode 100644 index 000000000..624291962 --- /dev/null +++ b/tests/test_certified_webmainbench_full.py @@ -0,0 +1,134 @@ +"""Scenario checks for the full-cohort evidence boundary.""" +import hashlib +import json +import subprocess +from pathlib import Path + +import pytest + +from scripts.run_certified_webmainbench_full import verify_shard, verify_declared_binary, verify_source_checkout +from scripts.assemble_webmainbench_full import public_value +from scripts.score_certified_webmainbench_full import verify_score +from scripts.score_webmainbench_results import aggregate + + +def _digest(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def _write_json(path: Path, value: object) -> None: + path.write_text(json.dumps(value), encoding="utf-8") + + +def _write_jsonl(path: Path, rows: list[dict]) -> None: + path.write_text("".join(json.dumps(row) + "\n" for row in rows), encoding="utf-8") + + +def _cohort(tmp_path: Path) -> Path: + shard = tmp_path / "moli" + artifact_dir = shard / "artifacts" + artifact_dir.mkdir(parents=True) + rows = [] + calls = [] + for case_id in ("one", "two"): + body = f"answer for {case_id}".encode() + logical_path = f"artifacts/{case_id}.md" + physical_path = f"artifacts/{case_id}-p01.txt" + (shard / logical_path).write_bytes(body) + (shard / physical_path).write_bytes(body) + rows.append({"provider": "moli", "track_id": case_id, "ok": True, + "artifact_path": logical_path, "output_sha256": _digest(body), + "physical_attempt_count": 1}) + calls.append({"attempt_id": f"moli-{case_id}-1", "track_id": case_id, + "physical_artifact_path": physical_path, "output_sha256": _digest(body)}) + _write_jsonl(shard / "results.jsonl", rows) + _write_jsonl(shard / "physical-attempts.jsonl", calls) + _write_json(shard / "dataset.json", {"case_count": 2, "case_ids": ["one", "two"]}) + _write_json(shard / "execution.json", {"case_count": 2, "provider": "moli", + "source_dataset_sha256": "source"}) + return shard + + +def test_complete_cohort_has_bound_logical_and_physical_evidence(tmp_path): + shard = _cohort(tmp_path) + result = verify_shard(shard, "moli", {"one", "two"}, + {"case_count": 2, "source_dataset_sha256": "source"}) + assert result["logical_results"] == result["physical_calls"] == 2 + + +def test_missing_case_cannot_be_certified(tmp_path): + shard = _cohort(tmp_path) + rows = [json.loads(line) for line in (shard / "results.jsonl").read_text().splitlines()] + _write_jsonl(shard / "results.jsonl", rows[:1]) + with pytest.raises(ValueError, match="incomplete or mismatched"): + verify_shard(shard, "moli", {"one", "two"}, + {"case_count": 2, "source_dataset_sha256": "source"}) + + +def test_changed_physical_output_cannot_be_certified(tmp_path): + shard = _cohort(tmp_path) + (shard / "artifacts/one-p01.txt").write_text("changed") + with pytest.raises(ValueError, match="physical artifact mismatch"): + verify_shard(shard, "moli", {"one", "two"}, + {"case_count": 2, "source_dataset_sha256": "source"}) + + +def test_declared_binary_is_checked_before_calls(tmp_path, monkeypatch): + binary = tmp_path / 'moli' + binary.write_bytes(b'fixed version') + binary.chmod(0o755) + monkeypatch.setenv('MOLI_BINARY', str(binary)) + verify_declared_binary({'binary_sha256': _digest(b'fixed version')}) + binary.write_bytes(b'old version') + with pytest.raises(ValueError, match='before provider calls'): + verify_declared_binary({'binary_sha256': _digest(b'fixed version')}) + + +def test_candidate_run_requires_declared_clean_source_commit(tmp_path): + subprocess.run(['git', 'init', '-q', str(tmp_path)], check=True) + (tmp_path / 'source.txt').write_text('fixed source') + subprocess.run(['git', '-C', str(tmp_path), 'add', 'source.txt'], check=True) + subprocess.run(['git', '-C', str(tmp_path), '-c', 'user.name=Test', + '-c', 'user.email=test@example.com', 'commit', '-qm', 'fixture'], check=True) + commit = subprocess.check_output(['git', '-C', str(tmp_path), 'rev-parse', 'HEAD'], text=True).strip() + verify_source_checkout({'source_commit': commit}, tmp_path) + (tmp_path / 'source.txt').write_text('changed after build') + with pytest.raises(ValueError, match='clean commit'): + verify_source_checkout({'source_commit': commit}, tmp_path) + + +def test_public_evidence_hides_local_build_path_without_losing_error(): + row = {"error_message": "panic at /Users/runner/.cargo/html5ever/mod.rs:254:9", + "meta": ["/private/tmp/run-123/output.txt", "/home/runner/.cargo/html5ever/mod.rs:254:9", + "/tmp/run-123/output.txt"]} + public = public_value(row) + assert "/Users/" not in json.dumps(public) + assert "/private/tmp/" not in json.dumps(public) + assert "/home/runner/" not in json.dumps(public) + assert "/tmp/run-123/" not in json.dumps(public) + assert "html5ever/mod.rs:254:9" in public["error_message"] + + +def test_scored_cohort_keeps_exact_measured_results_and_scorer_source(tmp_path): + shard = _cohort(tmp_path) + raw = [json.loads(line) for line in (shard / "results.jsonl").read_text().splitlines()] + names = ("text_edit", "code_edit", "formula_edit", "table_edit", "table_TEDS") + scored = [{**row, "metrics": {name: {"score": .5, "success": True} for name in names}} for row in raw] + _write_jsonl(shard / "scored-results.jsonl", scored) + scorer = tmp_path / "scorer" + scorer.mkdir() + (scorer / "metric.py").write_text("frozen scorer") + _write_json(shard / "score-summary.json", { + "provider": "moli", "case_count": 2, "official_aggregate": aggregate(scored), + "scorer": {"commit": "pinned", "metric_config": {"use_llm": False}, + "metric_source_sha256": {"metric.py": _digest(b"frozen scorer")}}, + }) + assert verify_score(shard, "moli", {"one", "two"}, scorer, { + "case_count": 2, "official_scorer_commit": "pinned", "metric_config": {"use_llm": False}, + })["logical_results"] == 2 + scored[0]["ok"] = False + _write_jsonl(shard / "scored-results.jsonl", scored) + with pytest.raises(ValueError, match="differs from measured"): + verify_score(shard, "moli", {"one", "two"}, scorer, { + "case_count": 2, "official_scorer_commit": "pinned", "metric_config": {"use_llm": False}, + }) diff --git a/tests/test_headless.py b/tests/test_headless.py index 2dc75e19a..02de29433 100644 --- a/tests/test_headless.py +++ b/tests/test_headless.py @@ -342,6 +342,15 @@ def test_https_fixture_ca_is_passed_to_native_clients(self): ) self.assertEqual(command[command.index('--ca-cert') + 1], '/tmp/fixture-ca.pem') + def test_frozen_origin_proxy_is_passed_to_every_native_client(self): + for engine in ('moli', 'lightpanda', 'obscura'): + command = cli_command( + engine, '/' + engine, 'https://example.test/page', 1.5, + 'markdown', http_proxy='http://127.0.0.1:8123', + ) + flag = '--proxy' if engine == 'obscura' else '--http-proxy' + self.assertEqual(command[command.index(flag) + 1], 'http://127.0.0.1:8123') + def test_obscura_https_fixture_uses_dedicated_ca_environment(self): from types import SimpleNamespace request = dict( diff --git a/tests/test_report_changes.py b/tests/test_report_changes.py index 96deccb02..72b69a52c 100644 --- a/tests/test_report_changes.py +++ b/tests/test_report_changes.py @@ -11,7 +11,9 @@ def test_webmainbench_history_is_localized_from_one_record(self): from harness.web_fetch.report_changes import ROOT source = json.loads((ROOT / 'research/report-changes.json').read_text(encoding='utf-8')) releases = webmainbench_history(source) - self.assertEqual(len(releases), 15) + expected = [row for row in source['releases'] + if 'webmainbench_frozen_html_to_markdown' in row.get('comparison_kinds', [])] + self.assertEqual([row['id'] for row in releases], [row['id'] for row in expected]) for english in (False, True): page = render_webmainbench_history( {'releases': releases}, english, diff --git a/tests/test_webmainbench_case_correction.py b/tests/test_webmainbench_case_correction.py new file mode 100644 index 000000000..5f53d9ed4 --- /dev/null +++ b/tests/test_webmainbench_case_correction.py @@ -0,0 +1,45 @@ +import hashlib +import json + +import pytest + +from harness.web_fetch.webmainbench_correction import validate + + +def test_complete_correction_rejects_partial_scope_changed_input_and_output(tmp_path): + names = ['moli', 'moli-fixed', 'chromium', 'lightpanda', 'obscura', 'kitesurf'] + digest = lambda p: hashlib.sha256(p.read_bytes()).hexdigest() + originals, new, calls = [], [], [] + for name in names: + artifact = tmp_path / (name + '.md') + artifact.write_text('狼人杀规则:村民投票,狼人夜间行动。') + old = dict(provider=name, case_id='case', attempt_id=name + '-old', + input_html_sha256='input', reference_sha256='reference', provider_metadata={}) + originals.append(old) + new.append({**old, 'attempt_id': name + '-new', 'supersedes_attempt_id': old['attempt_id'], + 'artifact_path': artifact.name, 'output_sha256': digest(artifact), 'physical_attempt_count': 1}) + calls.append(dict(provider=name, track_id='case', attempt_id=name + '-new', + physical_artifact_path=artifact.name, output_sha256=digest(artifact))) + plan = dict(providers=names, case_id='case', parent_results_sha256='parent') + def freeze(): + for filename, values in [('results.jsonl', new), ('physical-attempts.jsonl', calls)]: + (tmp_path / filename).write_text(''.join(json.dumps(r) + '\n' for r in values)) + (tmp_path / 'certificate.json').write_text(json.dumps(dict(kind='webmainbench_origin_correction_v1', + preflight=plan, postflight=plan, results_sha256=digest(tmp_path / 'results.jsonl'), + physical_sha256=digest(tmp_path / 'physical-attempts.jsonl')))) + freeze() + assert len(validate(tmp_path, originals, 'parent')[1]) == 6 + saved = new.pop() + freeze() + with pytest.raises(ValueError, match='every method'): + validate(tmp_path, originals, 'parent') + new.append(saved) + new[0]['input_html_sha256'] = 'changed' + freeze() + with pytest.raises(ValueError, match='frozen input'): + validate(tmp_path, originals, 'parent') + new[0]['input_html_sha256'] = 'input' + freeze() + (tmp_path / 'moli.md').write_text('empty error page') + with pytest.raises(ValueError, match='output changed'): + validate(tmp_path, originals, 'parent') diff --git a/tests/test_webmainbench_fixture.py b/tests/test_webmainbench_fixture.py index f0dcd4218..5e44fc87c 100644 --- a/tests/test_webmainbench_fixture.py +++ b/tests/test_webmainbench_fixture.py @@ -1,19 +1,49 @@ import ssl import unittest -from urllib.request import urlopen +from urllib.error import HTTPError +from urllib.request import HTTPSHandler, ProxyHandler, build_opener, urlopen from scripts.run_webmainbench_benchmark import fixture_server class FrozenFixtureTests(unittest.TestCase): + def test_default_fixture_keeps_the_frozen_profile_route(self): + record = {"track_id": "case-one", "html": "

Frozen case

", + "url": "https://source.example.test/article"} + with fixture_server([record]) as (base, ca_cert): + context = ssl.create_default_context(cafile=str(ca_cert)) + with urlopen(base + "/case/case-one?nocdn=true", context=context, timeout=5) as response: + self.assertEqual(response.read().decode(), record["html"]) + def test_inline_query_navigation_keeps_the_same_frozen_page(self): frozen = "

Frozen case

" - with fixture_server([{"track_id": "case-one", "html": frozen}]) as (base, ca_cert): + record = {"track_id": "case-one", "html": frozen, + "url": "https://source.example.test/article"} + with fixture_server([record], preserve_original_origin=True) as (proxy, ca_cert): context = ssl.create_default_context(cafile=str(ca_cert)) - with urlopen(base + "/case/case-one?nocdn=true", context=context, timeout=5) as response: + opener = build_opener(ProxyHandler({"https": proxy}), HTTPSHandler(context=context)) + with opener.open(record["url"] + "?nocdn=true", timeout=5) as response: self.assertEqual(response.status, 200) self.assertEqual(response.read().decode(), frozen) + def test_same_origin_path_uses_exact_frozen_query_without_crossing_cases(self): + records = [ + {"track_id": "first", "html": "

First

", + "url": "https://source.example.test/search?q=first"}, + {"track_id": "second", "html": "

Second

", + "url": "https://source.example.test/search?q=second"}, + ] + with fixture_server(records, preserve_original_origin=True) as (proxy, ca_cert): + context = ssl.create_default_context(cafile=str(ca_cert)) + opener = build_opener(ProxyHandler({"https": proxy}), HTTPSHandler(context=context)) + with opener.open(records[0]["url"], timeout=5) as response: + self.assertEqual(response.read().decode(), records[0]["html"]) + with opener.open(records[1]["url"], timeout=5) as response: + self.assertEqual(response.read().decode(), records[1]["html"]) + with self.assertRaises(HTTPError) as error: + opener.open("https://source.example.test/search?q=unknown", timeout=5) + self.assertEqual(error.exception.code, 404) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_webmainbench_full_report.py b/tests/test_webmainbench_full_report.py new file mode 100644 index 000000000..16ce812d3 --- /dev/null +++ b/tests/test_webmainbench_full_report.py @@ -0,0 +1,131 @@ +"""Reader-facing controls for the complete-cohort report renderer.""" +import json +import hashlib +import subprocess +from pathlib import Path +from bs4 import BeautifulSoup +import pytest + +from harness.web_fetch.report import generate_webmainbench_full +from scripts.report_presentation_contract import check_presentation +from scripts.check_webmainbench_full_report import check_diagnosis_inventory, check_result_payload + + +def test_full_report_renders_six_way_details_and_failure_evidence(tmp_path: Path) -> None: + providers = ["moli", "lightpanda", "obscura", "chromium", "kitesurf", "moli-fixed"] + case_id = "case-1" + metrics = ("text_edit", "code_edit", "formula_edit", "table_edit", "table_TEDS") + (tmp_path / "artifacts").mkdir() + rows = [] + physical = [] + summaries = {} + for index, provider in enumerate(providers): + text = ("" if provider == "moli" else + "# 无法访问此网站\nERR_ACCESS_DENIED" if provider == "chromium" else + f"response {provider}") + artifact = f"artifacts/{provider}.md" + (tmp_path / artifact).write_text(text, encoding="utf-8") + physical_path = f"artifacts/{provider}-p01.txt" + (tmp_path / physical_path).write_text(text, encoding="utf-8") + output_sha = hashlib.sha256(text.encode()).hexdigest() + physical.append({"provider": provider, "track_id": case_id, + "physical_artifact_path": physical_path, "output_sha256": output_sha, + "elapsed_ms": 100 + index}) + value = (index + 1) / 10 + scores = {metric: value for metric in metrics} + scores["overall"] = value + summaries[provider] = { + "official_aggregate": {"scores": scores, "denominators": {metric: 1 for metric in metrics}}, + "request_successes": 0 if provider == "moli" else 1, + } + rows.append({ + "attempt_id": provider + "__case-1__r001", "case_id": case_id, "provider": provider, + "original_url": "https://example.test/page", "ok": provider != "moli", + "output_chars": len(text), "artifact_path": artifact, "output_sha256": output_sha, + "elapsed_ms": 100 + index, "total_elapsed_ms": 100 + index, + "physical_attempt_count": 1, "provider_metadata": {"stderr": "fixture error"}, + "error_type": "fixture_error" if provider == "moli" else None, + "error_message": "fixture error" if provider == "moli" else None, + "metrics": {metric: {"score": value, "success": True} for metric in metrics}, + }) + dataset = {"cases": [{"id": case_id, "url": "https://example.test/page", + "meta": {"language": "en", "level": "easy"}, + "input_html_sha256": hashlib.sha256(b"

source

").hexdigest(), + "reference_sha256": hashlib.sha256(b"answer").hexdigest()}]} + (tmp_path / "sources").mkdir() + (tmp_path / "sources/case-1.html.txt").write_text("

source

") + (tmp_path / "references").mkdir() + (tmp_path / "references/case-1.md").write_text("answer") + profile = {"providers": providers, "versions": {provider: "1.0" for provider in providers}, + "source_dataset_sha256": "a" * 64, "official_scorer_commit": "b" * 40} + content_keys = ("home_title", "home_lead", "score_note", "latency_note", "all_latency_note", "diagnostic_note", "infographic_caption", + "dataset_lead", "analysis_lead", "method_lead", "method_input", + "method_scoring", "method_correction", "guide_lead", "guide_scope", + "guide_outputs", "guide_diagnosis", "guide_example", "reproduce_lead", + "guide_method_scope", "guide_conclusion") + (tmp_path / "report-content.json").write_text(json.dumps({key: "说明" for key in content_keys}), encoding="utf-8") + content = json.loads((tmp_path / "report-content.json").read_text()) + content["interfaces"] = {provider: "调用接口" for provider in providers} + content["adoption_lead"] = "采用证据" + content["adoption_limit"] = "采用限制" + content["review_scope_failure"] = "失败已复核" + content["review_scope_pass"] = "成功未复核" + content["kitesurf_version_note"] = "后端版本未披露" + content["fixed_quality"] = "成功结果仍需核对质量。" + content["fixed_mechanism"] = "修复解析器状态。" + for provider in providers: + content["guide_method_" + provider] = "逐项观察" + (tmp_path / "report-content.json").write_text(json.dumps(content), encoding="utf-8") + (tmp_path / "provider-adoption.json").write_text(json.dumps({"included": [], "cutoff": "2026-08-26"}), encoding="utf-8") + (tmp_path / "failure-analysis.json").write_text(json.dumps({"reviewed_failures": [ + {"provider": "moli", "case_id": case_id, "category": "browser_exit", "category_label": "Moli 解析器崩溃", + "cause": "Fixture failed", "treatment": "Investigate", "status": "open"}]}), encoding="utf-8") + (tmp_path / "physical-attempts.jsonl").write_text("".join(json.dumps(row) + "\n" for row in physical), encoding="utf-8") + generate_webmainbench_full(tmp_path, dataset, rows, summaries, profile) + subprocess.run(["node", "--check", str(tmp_path / "assets/report.js")], check=True) + home = check_presentation((tmp_path / "index.html").read_text(encoding="utf-8")) + assert len(home.select('td[data-metric-direction="max"]')) == 36 + assert len(home.select('td[data-metric-direction="max"] .comparison-winner')) == 6 + assert home.select_one('td[data-target-body-provider="chromium"]').get_text(strip=True) == "0/545" + assert home.select_one('td[data-target-body-provider="moli-fixed"]').get_text(strip=True) == "1/545" + assert "目标正文" in home.get_text() + detail = check_presentation((tmp_path / "results/index.html").read_text(encoding="utf-8")) + assert detail.select_one('[data-failure-diagnosis]') is not None + comparison = BeautifulSoup((tmp_path / "cases/index.html").read_text(encoding="utf-8"), "html.parser") + assert comparison.select_one("#case-results") is not None + assert comparison.select_one("#case-comparison") is not None + assert comparison.select_one("#case-reference") is not None + analysis = BeautifulSoup((tmp_path / "analysis/index.html").read_text(encoding="utf-8"), "html.parser") + check_diagnosis_inventory(analysis) + assert len(analysis.select("tr[data-result]")) == 6 + assert analysis.select_one('tr[data-provider="moli-fixed"]') is not None + assert analysis.select_one("#cause") is not None + assert analysis.select_one('tr[data-cause="Moli 解析器崩溃"]') is not None + missing_dialog = BeautifulSoup(str(analysis).replace('id="diagnosis-error"', 'id="missing-error"'), 'html.parser') + with pytest.raises(ValueError, match='diagnosis dialog'): + check_diagnosis_inventory(missing_dialog) + missing_button = BeautifulSoup(str(analysis).replace('data-diagnose=""', 'data-missing=""', 1), 'html.parser') + with pytest.raises(ValueError, match='cannot open'): + check_diagnosis_inventory(missing_button) + assert json.loads((tmp_path / "report-manifest.json").read_text())["languages"] == ["zh"] + home_html = (tmp_path / "index.html").read_text(encoding="utf-8") + with pytest.raises(ValueError, match="extremum"): + check_presentation(home_html.replace("winner comparison-winner", "winner", 1)) + detail_html = (tmp_path / "results/index.html").read_text(encoding="utf-8") + index = json.loads((tmp_path / "result-shard-index.json").read_text()) + artifact_index = json.loads((tmp_path / "artifact-shard-index.json").read_text()) + shards = {path: json.loads((tmp_path / path).read_text()) for path in set(index.values())} + check_result_payload(rows[0], index, artifact_index, shards) + with pytest.raises(ValueError, match="Evidence target"): + check_result_payload(rows[0], index, {**artifact_index, rows[0]["artifact_path"]: "shards/missing.json"}, shards) + assert not (tmp_path / "artifacts").exists() + assert not (tmp_path / "sources").exists() + assert not (tmp_path / "references").exists() + assert len(list((tmp_path / "shards").glob("*.json"))) == 7 + with pytest.raises(ValueError, match="Out-of-cohort"): + check_presentation(home_html.replace('data-metric-direction="neutral"', + 'data-metric-direction="neutral">bad