Skip to main content

llm_verify/
html.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Self-contained HTML report.
3//!
4//! Everything is inlined — no CDN, no fonts, no scripts fetched at view time —
5//! so the file can be mailed to a provider as evidence and still render years
6//! later. Charts are hand-built SVG with CSS animation rather than a charting
7//! library, which keeps the artefact under a few hundred kilobytes.
8
9use crate::i18n::Lang;
10use crate::report::*;
11use crate::util::html_escape as esc;
12use std::fmt::Write;
13
14const CSS: &str = r#"
15:root{
16  --ground:#F3F6F7; --ground-2:#E8EDF0;
17  --surface:#FFFFFF; --surface-2:#EEF2F5; --surface-3:#DFE6EA;
18  --ink:#101619; --ink-2:#3A464D; --muted:#697680;
19  --rule:#DCE3E7; --rule-2:#BFCAD1;
20  --accent:#0D5A66; --accent-2:#13818F; --accent-wash:#DEEEF0;
21  --pass:#1B6E48; --pass-2:#2E9A68;
22  --warn:#8C6115; --warn-2:#C08A2A;
23  --fail:#A3352B; --fail-2:#CB5A4C;
24  --skip:#7A868D; --skip-2:#9AA5AB;
25  --pass-bg:#E4F3EA; --warn-bg:#FBF1DE; --fail-bg:#FBE6E3; --skip-bg:#ECEFF1;
26  --shadow-1:0 1px 2px rgba(16,26,30,.05);
27  --shadow-2:0 1px 2px rgba(16,26,30,.05),0 12px 30px -16px rgba(16,26,30,.22);
28  --radius:13px; --radius-sm:9px; --radius-xs:6px;
29  --mono:ui-monospace,"SF Mono",SFMono-Regular,"Cascadia Mono",Menlo,Consolas,monospace;
30  --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif;
31}
32@media (prefers-color-scheme:dark){
33  :root:not([data-theme="light"]){
34    --ground:#0C1013; --ground-2:#10161A;
35    --surface:#151B1F; --surface-2:#1C2429; --surface-3:#263036;
36    --ink:#E3E9EC; --ink-2:#B0BCC3; --muted:#84919A;
37    --rule:#222B31; --rule-2:#35434C;
38    --accent:#4EA8B6; --accent-2:#7FC7D2; --accent-wash:#0F2E34;
39    --pass:#4FAE81; --pass-2:#79CBA3;
40    --warn:#C99A45; --warn-2:#E0B769;
41    --fail:#DB7568; --fail-2:#EC9A8F;
42    --skip:#78868E; --skip-2:#94A1A8;
43    --pass-bg:#12291F; --warn-bg:#2B2213; --fail-bg:#2E1A18; --skip-bg:#1C2328;
44    --shadow-1:0 1px 2px rgba(0,0,0,.4);
45    --shadow-2:0 1px 2px rgba(0,0,0,.4),0 12px 30px -16px rgba(0,0,0,.75);
46  }
47}
48:root[data-theme="dark"]{
49  --ground:#0C1013; --ground-2:#10161A;
50  --surface:#151B1F; --surface-2:#1C2429; --surface-3:#263036;
51  --ink:#E3E9EC; --ink-2:#B0BCC3; --muted:#84919A;
52  --rule:#222B31; --rule-2:#35434C;
53  --accent:#4EA8B6; --accent-2:#7FC7D2; --accent-wash:#0F2E34;
54  --pass:#4FAE81; --pass-2:#79CBA3;
55  --warn:#C99A45; --warn-2:#E0B769;
56  --fail:#DB7568; --fail-2:#EC9A8F;
57  --skip:#78868E; --skip-2:#94A1A8;
58  --pass-bg:#12291F; --warn-bg:#2B2213; --fail-bg:#2E1A18; --skip-bg:#1C2328;
59  --shadow-1:0 1px 2px rgba(0,0,0,.4);
60  --shadow-2:0 1px 2px rgba(0,0,0,.4),0 12px 30px -16px rgba(0,0,0,.75);
61}
62
63*{box-sizing:border-box}
64html{scroll-behavior:smooth}
65body{margin:0;color:var(--ink);font-family:var(--sans);font-size:15px;line-height:1.7;
66     -webkit-font-smoothing:antialiased;
67     background:var(--ground);
68     background-image:radial-gradient(900px 380px at 78% -8%,var(--accent-wash),transparent 62%);
69     background-repeat:no-repeat}
70.wrap{max-width:1140px;margin:0 auto;padding:0 26px}
71h1,h2,h3{font-family:var(--mono);font-weight:600;letter-spacing:-.012em;margin:0;text-wrap:balance}
72p{text-wrap:pretty}
73code{font-family:var(--mono)}
74
75/* ── entrance animation ─────────────────────────────── */
76.rise{opacity:0;transform:translateY(14px);animation:rise .55s cubic-bezier(.2,.7,.3,1) forwards}
77@keyframes rise{to{opacity:1;transform:none}}
78main>section:nth-of-type(1){animation-delay:.02s}
79main>section:nth-of-type(2){animation-delay:.06s}
80main>section:nth-of-type(3){animation-delay:.10s}
81main>section:nth-of-type(4){animation-delay:.14s}
82main>section:nth-of-type(5){animation-delay:.18s}
83main>section:nth-of-type(6){animation-delay:.22s}
84main>section:nth-of-type(7){animation-delay:.26s}
85main>section:nth-of-type(n+8){animation-delay:.30s}
86@media (prefers-reduced-motion:reduce){
87  html{scroll-behavior:auto}
88  .rise{animation:none;opacity:1;transform:none}
89  .donut-ring,.bar-fill,.dist-bar,.tally-seg,.verdict-chip .dot{animation:none !important}
90  .donut-ring{stroke-dashoffset:var(--final) !important}
91  .bar-fill,.tally-seg{width:var(--w) !important}
92  .dist-bar{height:var(--h) !important}
93}
94
95/* ── masthead ───────────────────────────────────────── */
96.mast{border-bottom:1px solid var(--rule);
97      background:linear-gradient(180deg,var(--surface),color-mix(in srgb,var(--surface) 84%,transparent))}
98.mast .wrap{padding:26px 26px 22px}
99.brand{display:flex;align-items:center;gap:11px;flex-wrap:wrap}
100.brand .mark{width:22px;height:22px;border-radius:6px;flex:0 0 auto;
101             background:linear-gradient(135deg,var(--accent-2),var(--accent));
102             box-shadow:0 0 0 1px color-mix(in srgb,var(--accent) 30%,transparent) inset;
103             display:grid;place-content:center;color:#fff;font-family:var(--mono);
104             font-size:12px;font-weight:700;line-height:1}
105.brand .name{font-family:var(--mono);font-size:15px;font-weight:600;color:var(--ink);letter-spacing:-.02em}
106.brand .ver{font-family:var(--mono);font-size:11.5px;color:var(--muted);
107            padding:2px 8px;border:1px solid var(--rule);border-radius:999px}
108.brand .stamp{margin-left:auto;font-family:var(--mono);font-size:11.5px;color:var(--muted)}
109.target{margin-top:16px;display:grid;gap:10px 14px;
110        grid-template-columns:repeat(auto-fit,minmax(215px,1fr))}
111.target div{background:var(--surface-2);border:1px solid var(--rule);border-radius:var(--radius-xs);
112            padding:7px 11px;min-width:0}
113.target b{display:block;font-family:var(--mono);font-size:10px;letter-spacing:.12em;
114          text-transform:uppercase;color:var(--muted);font-weight:600}
115.target span{display:block;font-family:var(--mono);font-size:12.5px;color:var(--ink-2);
116             overflow-wrap:anywhere;margin-top:1px}
117
118/* ── section index ──────────────────────────────────── */
119.toc{position:sticky;top:0;z-index:30;background:var(--ground);
120     border-bottom:1px solid var(--rule);margin-top:30px}
121.toc .wrap{display:flex;gap:2px;overflow-x:auto;padding:0 20px;scrollbar-width:thin}
122.toc a{font-family:var(--mono);font-size:11.5px;letter-spacing:.02em;color:var(--muted);
123       text-decoration:none;padding:12px 11px 10px;white-space:nowrap;
124       border-bottom:2px solid transparent;transition:color .15s,border-color .15s}
125.toc a:hover{color:var(--accent);border-bottom-color:var(--accent)}
126
127/* ── hero ───────────────────────────────────────────── */
128.hero{position:relative;overflow:hidden;margin:28px 0 0;
129      display:grid;grid-template-columns:auto 1fr;gap:34px;align-items:center;
130      background:var(--surface);border:1px solid var(--rule);border-radius:var(--radius);
131      padding:28px 32px;box-shadow:var(--shadow-2)}
132.hero:before{content:"";position:absolute;inset:0;pointer-events:none;
133             background:radial-gradient(560px 220px at 8% 0%,var(--accent-wash),transparent 70%)}
134.hero>*{position:relative}
135@media (max-width:760px){.hero{grid-template-columns:1fr;gap:22px;padding:24px 22px}}
136.donut{position:relative;width:170px;height:170px;flex:0 0 auto;margin:0 auto}
137.donut svg{transform:rotate(-90deg);display:block}
138.donut-track{fill:none;stroke:var(--surface-3);stroke-width:12}
139.donut-ring{fill:none;stroke-width:12;stroke-linecap:round;
140            stroke-dasharray:var(--circ);stroke-dashoffset:var(--circ);
141            animation:draw 1.3s cubic-bezier(.25,.8,.3,1) .25s forwards}
142@keyframes draw{to{stroke-dashoffset:var(--final)}}
143.donut-label{position:absolute;inset:0;display:grid;place-content:center;text-align:center}
144.donut-label .n{font-family:var(--mono);font-size:40px;font-weight:600;line-height:1;
145                letter-spacing:-.03em;font-variant-numeric:tabular-nums}
146.donut-label .u{font-size:10px;color:var(--muted);font-family:var(--mono);letter-spacing:.16em;
147                text-transform:uppercase;margin-top:6px}
148
149.verdict-chip{display:inline-flex;align-items:center;gap:9px;padding:6px 15px;border-radius:999px;
150              font-weight:600;font-size:14.5px;border:1px solid transparent;line-height:1.45}
151.verdict-chip .dot{width:8px;height:8px;border-radius:50%;background:currentColor;
152                   animation:pulse 2.4s ease-in-out infinite}
153@keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
154.v-good{background:var(--pass-bg);color:var(--pass);border-color:color-mix(in srgb,var(--pass) 30%,transparent)}
155.v-mid{background:var(--accent-wash);color:var(--accent);border-color:color-mix(in srgb,var(--accent) 30%,transparent)}
156.v-warn{background:var(--warn-bg);color:var(--warn);border-color:color-mix(in srgb,var(--warn) 30%,transparent)}
157.v-bad{background:var(--fail-bg);color:var(--fail);border-color:color-mix(in srgb,var(--fail) 30%,transparent)}
158.v-none{background:var(--skip-bg);color:var(--skip);border-color:var(--rule-2)}
159
160.hero-body h1{font-size:clamp(23px,3.2vw,32px);margin:15px 0 8px;overflow-wrap:anywhere}
161.hero-body>p{margin:0;color:var(--ink-2);max-width:64ch}
162.axes{margin-top:20px;display:grid;gap:1px;background:var(--rule);border:1px solid var(--rule);
163      border-radius:var(--radius-sm);overflow:hidden;
164      grid-template-columns:repeat(auto-fit,minmax(155px,1fr))}
165.axes div{background:var(--surface);padding:10px 14px;min-width:0}
166.axes .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;
167         color:var(--muted)}
168.axes .v{font-size:13.5px;color:var(--ink);margin-top:2px;font-weight:500;overflow-wrap:anywhere}
169
170/* probe tally meter */
171.tally{margin-top:18px}
172.tally-bar{display:flex;height:8px;border-radius:999px;overflow:hidden;background:var(--surface-3)}
173.tally-seg{width:0;animation:grow 1s cubic-bezier(.22,.8,.3,1) .4s forwards}
174.tally-keys{margin-top:9px;display:flex;flex-wrap:wrap;gap:6px 16px;
175            font-family:var(--mono);font-size:11.5px;color:var(--muted)}
176.tally-keys span{display:inline-flex;align-items:center;gap:6px}
177.tally-keys i{width:7px;height:7px;border-radius:2px;display:inline-block}
178.tally-keys b{color:var(--ink);font-weight:600;font-variant-numeric:tabular-nums}
179
180/* ── gate alert ─────────────────────────────────────── */
181.gates{margin:26px 0 0;border:1px solid color-mix(in srgb,var(--fail) 42%,transparent);
182       border-left:4px solid var(--fail);border-radius:var(--radius-sm);
183       background:var(--fail-bg);overflow:hidden;box-shadow:var(--shadow-1)}
184.gates header{padding:12px 18px;font-family:var(--mono);font-size:13px;font-weight:600;
185              color:var(--fail);border-bottom:1px solid color-mix(in srgb,var(--fail) 25%,transparent);
186              display:flex;align-items:center;gap:9px}
187.gates ul{margin:0;padding:13px 20px 15px 38px}
188.gates li{margin-bottom:7px;font-size:14px;color:var(--ink)}
189.gates li:last-child{margin-bottom:0}
190.gates li b{font-family:var(--mono);font-size:13px}
191.gates li .src{color:var(--muted);font-size:12px;font-family:var(--mono)}
192
193/* ── layout ─────────────────────────────────────────── */
194main{counter-reset:sec}
195section{padding:38px 0 4px;scroll-margin-top:52px;counter-increment:sec}
196.sec-head{display:flex;align-items:baseline;gap:12px;margin-bottom:14px;flex-wrap:wrap}
197.sec-head:before{content:counter(sec,decimal-leading-zero);font-family:var(--mono);font-size:10.5px;
198                 font-weight:600;letter-spacing:.1em;color:var(--accent);align-self:center;
199                 padding:3px 8px;border-radius:999px;background:var(--accent-wash);
200                 border:1px solid color-mix(in srgb,var(--accent) 24%,transparent)}
201.sec-head h2{font-size:18.5px}
202.sec-head .hint{font-size:13px;color:var(--muted)}
203.grid{display:grid;gap:14px}
204/* 390px, not 330: a `.g2` section holding two cards plus a full-width one
205   would otherwise fit three tracks and leave the first row half empty. */
206.g2{grid-template-columns:repeat(auto-fit,minmax(min(100%,390px),1fr))}
207.g4{grid-template-columns:repeat(auto-fit,minmax(180px,1fr))}
208.span-all{grid-column:1/-1}
209.card{background:var(--surface);border:1px solid var(--rule);border-radius:var(--radius);
210      padding:18px 20px;box-shadow:var(--shadow-1);
211      transition:box-shadow .2s ease,border-color .2s ease}
212.card:hover{box-shadow:var(--shadow-2);border-color:var(--rule-2)}
213.card h3{font-size:11px;letter-spacing:.11em;text-transform:uppercase;color:var(--muted);
214         margin-bottom:12px}
215.card h3+.bars,.card h3+.cmp,.card h3+.dl{margin-top:-2px}
216
217/* ── stat tiles ─────────────────────────────────────── */
218.stat{position:relative;overflow:hidden;padding-top:20px}
219.stat:before{content:"";position:absolute;top:0;left:0;right:0;height:3px;
220             background:var(--rule-2)}
221.stat.good:before{background:linear-gradient(90deg,var(--pass),var(--pass-2))}
222.stat.warn:before{background:linear-gradient(90deg,var(--warn),var(--warn-2))}
223.stat.bad:before{background:linear-gradient(90deg,var(--fail),var(--fail-2))}
224.stat .v{font-family:var(--mono);font-size:30px;font-weight:600;line-height:1.15;
225         letter-spacing:-.025em;font-variant-numeric:tabular-nums}
226.stat .s{font-size:12.5px;color:var(--muted);margin-top:5px;text-wrap:pretty}
227.stat .v.good{color:var(--pass)} .stat .v.warn{color:var(--warn)} .stat .v.bad{color:var(--fail)}
228
229/* ── bars ───────────────────────────────────────────── */
230.bars{display:grid;gap:10px}
231.bar-row{display:grid;grid-template-columns:clamp(96px,26%,180px) 1fr 54px;gap:12px;
232         align-items:center;font-size:13px}
233@media (max-width:520px){.bar-row{grid-template-columns:92px 1fr 46px;gap:9px}}
234.bar-row .lbl{color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
235.bar-track{height:9px;border-radius:999px;background:var(--surface-3);overflow:hidden;
236           box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--rule-2) 45%,transparent)}
237.bar-fill{height:100%;border-radius:999px;width:0;
238          animation:grow 1s cubic-bezier(.22,.8,.3,1) .15s forwards}
239@keyframes grow{to{width:var(--w)}}
240.bar-row .num{font-family:var(--mono);font-size:12.5px;text-align:right;
241              font-variant-numeric:tabular-nums;color:var(--ink-2)}
242
243/* ── key/value list ─────────────────────────────────── */
244.dl{display:grid}
245.dl-row{display:flex;justify-content:space-between;align-items:baseline;gap:18px;
246        padding:9px 0;border-bottom:1px dashed var(--rule)}
247.dl-row:last-child{border-bottom:none;padding-bottom:2px}
248.dl-row .k{color:var(--muted);font-size:12.5px;white-space:nowrap}
249.dl-row .v{font-family:var(--mono);font-size:12.5px;text-align:right;
250           font-variant-numeric:tabular-nums;color:var(--ink);overflow-wrap:anywhere}
251
252/* ── billing comparison ─────────────────────────────── */
253.cmp{display:grid;gap:14px}
254.cmp-row .top{display:flex;justify-content:space-between;align-items:baseline;
255              font-size:12.5px;margin-bottom:6px;gap:12px}
256.cmp-row .top .k{color:var(--muted)}
257.cmp-row .top .n{font-family:var(--mono);font-variant-numeric:tabular-nums;color:var(--ink)}
258.ratio-note{margin-top:15px;padding:11px 14px;border-radius:var(--radius-sm);font-size:13.5px;
259            line-height:1.6;border:1px solid transparent}
260.ratio-ok{background:var(--pass-bg);color:var(--pass);border-color:color-mix(in srgb,var(--pass) 22%,transparent)}
261.ratio-hi{background:var(--warn-bg);color:var(--warn);border-color:color-mix(in srgb,var(--warn) 22%,transparent)}
262.ratio-bad{background:var(--fail-bg);color:var(--fail);border-color:color-mix(in srgb,var(--fail) 22%,transparent)}
263.foot-note{margin-top:12px;font-size:12.5px;color:var(--muted);text-wrap:pretty}
264
265/* ── distribution chart ─────────────────────────────── */
266.dist{position:relative;display:flex;align-items:flex-end;gap:4px;height:112px;
267      padding:10px 2px 0;border-bottom:1px solid var(--rule-2);
268      background-image:repeating-linear-gradient(to bottom,var(--rule) 0 1px,transparent 1px 25%)}
269/* stretch, not flex-end: a percentage bar height needs a resolved parent
270   height, and an auto-height column silently collapses every bar to zero. */
271.dist-col{flex:1;align-self:stretch;display:flex;flex-direction:column;justify-content:flex-end;
272          min-width:4px;position:relative;z-index:1}
273.dist-bar{width:100%;border-radius:3px 3px 0 0;height:0;
274          background:linear-gradient(to top,var(--accent),var(--accent-2));
275          animation:rise-bar .8s cubic-bezier(.22,.8,.3,1) forwards;opacity:.9}
276.dist-col:hover .dist-bar{opacity:1}
277@keyframes rise-bar{to{height:var(--h)}}
278.dist-mark{position:absolute;left:0;right:0;border-top:1px dashed var(--fail);z-index:2;
279           pointer-events:none}
280.dist-mark span{position:absolute;right:0;top:-8px;font-family:var(--mono);font-size:9.5px;
281                color:var(--fail);background:var(--surface);padding:0 4px;border-radius:3px;
282                letter-spacing:.04em}
283.dist-axis{display:flex;justify-content:space-between;font-family:var(--mono);font-size:10.5px;
284           color:var(--muted);margin-top:7px}
285
286/* ── hop chain ──────────────────────────────────────── */
287.chain{list-style:none;display:flex;align-items:center;gap:9px;flex-wrap:wrap;
288       margin:0 0 16px;padding:0}
289.chain .hop{font-family:var(--mono);font-size:12px;padding:6px 12px;border-radius:999px;
290     background:var(--accent-wash);color:var(--accent);
291     border:1px solid color-mix(in srgb,var(--accent) 26%,transparent)}
292.chain .hop.you{background:var(--surface-2);color:var(--ink-2);border-color:var(--rule-2)}
293.chain .arrow{color:var(--rule-2);font-family:var(--mono);font-size:14px;line-height:1}
294
295/* ── evidence lists ─────────────────────────────────── */
296.ev{margin:0;padding-left:19px;font-size:13.5px;color:var(--ink-2)}
297.ev li{margin-bottom:5px}
298.ev li::marker{color:var(--rule-2)}
299
300/* ── probe table ────────────────────────────────────── */
301.tbl-wrap{overflow-x:auto;border:1px solid var(--rule);border-radius:var(--radius);
302          background:var(--surface);box-shadow:var(--shadow-1)}
303table{border-collapse:collapse;width:100%;min-width:680px;font-size:13.5px}
304thead th{font-family:var(--mono);font-size:10px;letter-spacing:.11em;text-transform:uppercase;
305         color:var(--muted);font-weight:600;text-align:left;padding:11px 15px;
306         background:var(--surface-2);border-bottom:1px solid var(--rule-2);white-space:nowrap}
307tbody td{padding:10px 15px;border-bottom:1px solid var(--rule);vertical-align:top}
308tbody tr:last-child td{border-bottom:none}
309tbody tr:not(.grp):hover td{background:color-mix(in srgb,var(--surface-2) 55%,transparent)}
310tbody tr.grp td{background:var(--surface-2);padding:9px 15px;border-bottom:1px solid var(--rule-2)}
311.grp-head{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap}
312.grp-head b{font-family:var(--mono);font-size:11.5px;letter-spacing:.06em;color:var(--ink-2);
313            font-weight:600;text-transform:uppercase}
314.grp-head span{font-size:12px;color:var(--muted)}
315.grp-head em{margin-left:auto;font-style:normal;font-family:var(--mono);font-size:11px;
316             color:var(--muted);font-variant-numeric:tabular-nums}
317td.st{width:78px;white-space:nowrap}
318td.pid{font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap}
319td.plabel{font-weight:600;white-space:nowrap}
320td.psum{color:var(--ink-2)}
321td.pms{font-family:var(--mono);font-size:12px;text-align:right;color:var(--muted);
322       white-space:nowrap;font-variant-numeric:tabular-nums}
323.tag{display:inline-flex;align-items:center;gap:5px;font-family:var(--mono);font-size:11px;
324     font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid transparent}
325.tag.pass{background:var(--pass-bg);color:var(--pass);border-color:color-mix(in srgb,var(--pass) 22%,transparent)}
326.tag.warn{background:var(--warn-bg);color:var(--warn);border-color:color-mix(in srgb,var(--warn) 22%,transparent)}
327.tag.fail{background:var(--fail-bg);color:var(--fail);border-color:color-mix(in srgb,var(--fail) 22%,transparent)}
328.tag.skip{background:var(--skip-bg);color:var(--skip);border-color:var(--rule-2)}
329.tag.err{background:var(--fail-bg);color:var(--fail);border-color:color-mix(in srgb,var(--fail) 22%,transparent)}
330.neutral-note{font-weight:400;font-size:11px;color:var(--muted)}
331details.detail{margin-top:7px}
332details.detail summary{cursor:pointer;font-size:12px;color:var(--accent);font-family:var(--mono);
333                       list-style:none;display:inline-block;padding:1px 0}
334details.detail summary::-webkit-details-marker{display:none}
335details.detail summary:before{content:"▸ "}
336details.detail[open] summary:before{content:"▾ "}
337details.detail summary:hover{text-decoration:underline}
338.detail-body{margin-top:8px;padding:11px 14px;background:var(--surface-2);
339             border-radius:var(--radius-xs);font-size:12.5px;color:var(--ink-2);
340             border-left:2px solid var(--accent)}
341.detail-body ul{margin:0 0 8px;padding-left:17px}
342.detail-body pre{margin:7px 0 0;white-space:pre-wrap;overflow-wrap:anywhere;
343                 font-family:var(--mono);font-size:11.5px;color:var(--ink);
344                 background:var(--surface);padding:10px 12px;border-radius:var(--radius-xs);
345                 border:1px solid var(--rule);max-height:230px;overflow:auto}
346.kv{display:flex;flex-wrap:wrap;gap:5px 8px;margin-top:6px}
347.kv code{font-size:11px;background:var(--surface);padding:2px 7px;border-radius:999px;
348         border:1px solid var(--rule);color:var(--ink-2)}
349
350/* ── trace / notes ──────────────────────────────────── */
351ol.trace{margin:0;padding-left:24px;font-family:var(--mono);font-size:12.5px;color:var(--ink-2)}
352ol.trace li{margin-bottom:6px;padding-left:2px}
353ol.trace li::marker{color:var(--accent);font-size:11px}
354.note{border-left:3px solid var(--accent);background:var(--surface-2);padding:14px 18px;
355      border-radius:0 var(--radius-sm) var(--radius-sm) 0;font-size:13.5px;line-height:1.7;
356      color:var(--ink-2)}
357.note b{color:var(--ink)}
358.note.warnbox{border-left-color:var(--warn)}
359.limits{margin:0;padding:0;list-style:none;display:grid;gap:10px;
360        grid-template-columns:repeat(auto-fit,minmax(330px,1fr))}
361.limits li{background:var(--surface);border:1px solid var(--rule);border-radius:var(--radius-sm);
362           padding:14px 17px;font-size:13px;color:var(--ink-2);line-height:1.65}
363.limits li b{display:block;color:var(--ink);font-family:var(--mono);font-size:12.5px;
364             margin-bottom:4px;letter-spacing:-.01em}
365
366footer{padding:30px 0 52px;color:var(--muted);font-size:12px;font-family:var(--mono);
367       border-top:1px solid var(--rule);margin-top:38px}
368footer p{margin:4px 0;overflow-wrap:anywhere}
369footer a{color:var(--accent);text-decoration:none}
370footer a:hover{text-decoration:underline}
371footer .disclaimer{margin-top:12px;font-family:var(--sans);font-size:12.5px;line-height:1.6;
372                   max-width:76ch}
373
374/* ── print ──────────────────────────────────────────── */
375@media print{
376  .toc{display:none}
377  body{background:#fff;background-image:none;color:#000;font-size:11pt}
378  .rise{animation:none;opacity:1;transform:none}
379  .donut-ring{animation:none;stroke-dashoffset:var(--final)}
380  .bar-fill,.tally-seg{animation:none;width:var(--w)}
381  .dist-bar{animation:none;height:var(--h)}
382  .verdict-chip .dot{animation:none}
383  .card,.hero,.tbl-wrap,.limits li{box-shadow:none;break-inside:avoid}
384  section{break-inside:avoid;padding-top:22px}
385  details.detail[open] .detail-body{break-inside:avoid}
386  details.detail:not([open]) summary{display:none}
387}
388"#;
389
390/// Bar colour by percentage, shared by every scored bar in the report.
391fn score_colour(pct: f64) -> &'static str {
392    if pct >= 85.0 {
393        "var(--pass)"
394    } else if pct >= 60.0 {
395        "var(--warn)"
396    } else {
397        "var(--fail)"
398    }
399}
400
401/// The same three bands as [`score_colour`], as a gradient for filled bars.
402fn score_gradient(pct: f64) -> &'static str {
403    if pct >= 85.0 {
404        "linear-gradient(90deg,var(--pass),var(--pass-2))"
405    } else if pct >= 60.0 {
406        "linear-gradient(90deg,var(--warn),var(--warn-2))"
407    } else {
408        "linear-gradient(90deg,var(--fail),var(--fail-2))"
409    }
410}
411
412/// `.dist` is 112px tall with 10px of top padding, so the bars occupy the
413/// lower 102px. Keep this in step with the stylesheet.
414const DIST_PLOT_RATIO: f64 = 102.0 / 112.0;
415
416fn stat_class(pct: f64) -> &'static str {
417    if pct >= 85.0 {
418        "good"
419    } else if pct >= 60.0 {
420        "warn"
421    } else {
422        "bad"
423    }
424}
425
426/// Percentile of an unsorted sample, used for the reference lines on the
427/// distribution charts.
428fn percentile(values: &[f64], p: f64) -> f64 {
429    if values.is_empty() {
430        return 0.0;
431    }
432    let mut v = values.to_vec();
433    v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
434    let idx = ((v.len() - 1) as f64 * p).round() as usize;
435    v[idx]
436}
437
438pub fn render(rep: &Report) -> String {
439    let mut h = String::with_capacity(96 * 1024);
440    let l = rep.lang;
441
442    let _ = write!(
443        h,
444        r#"<!doctype html>
445<html lang="{lang}">
446<head>
447<meta charset="utf-8">
448<meta name="viewport" content="width=device-width,initial-scale=1">
449<meta name="report-language" content="{lang}">
450<meta name="generator" content="llm-verify {ver}">
451<title>llm-verify · {host}</title>
452<style>{CSS}</style>
453</head>
454<body>
455"#,
456        lang = l.html_lang(),
457        ver = esc(&rep.tool_version),
458        host = esc(&rep.host),
459    );
460
461    masthead(&mut h, rep);
462    let _ = write!(h, r#"<div class="wrap">"#);
463    hero(&mut h, rep);
464    let _ = write!(h, "</div>");
465    toc(&mut h, rep);
466
467    let _ = write!(h, r#"<main class="wrap">"#);
468    gates(&mut h, rep);
469    key_stats(&mut h, rep);
470    group_scores(&mut h, rep);
471    identity_panel(&mut h, rep);
472    billing_panel(&mut h, rep);
473    perf_panel(&mut h, rep);
474    channel_panel(&mut h, rep);
475    probe_table(&mut h, rep);
476    trace_panel(&mut h, rep);
477    limits_panel(&mut h, l);
478    let _ = write!(h, "</main>");
479
480    footer(&mut h, rep);
481    let _ = write!(h, "\n</body>\n</html>\n");
482    h
483}
484
485/// Section anchors, in document order, with the short label the index uses.
486/// Kept in one place so the index at the top and the sections below can never
487/// drift apart. The labels are deliberately shorter than the section headings:
488/// the index is one line and has to survive nine entries on a laptop.
489fn sections(rep: &Report) -> Vec<(&'static str, &'static str)> {
490    let l = rep.lang;
491    let mut v = vec![
492        ("numbers", ts!(l, "Key numbers", "关键指标")),
493        ("groups", ts!(l, "Group scores", "分组得分")),
494        ("identity", ts!(l, "Identity", "模型身份")),
495        ("billing", ts!(l, "Billing", "计量计费")),
496    ];
497    if rep.perf.samples > 0 {
498        v.push(("perf", ts!(l, "Performance", "性能")));
499    }
500    v.push(("channel", ts!(l, "Provenance", "渠道来源")));
501    v.push(("probes", ts!(l, "Probes", "探针明细")));
502    v.push(("verdict", ts!(l, "Verdict", "判定过程")));
503    v.push(("limits", ts!(l, "Limits", "能力边界")));
504    v
505}
506
507fn toc(h: &mut String, rep: &Report) {
508    let _ = write!(h, r#"<nav class="toc"><div class="wrap">"#);
509    for (id, label) in sections(rep) {
510        let _ = write!(h, r##"<a href="#{id}">{}</a>"##, esc(label));
511    }
512    let _ = write!(h, "</div></nav>");
513}
514
515/// Open a numbered section. The number itself comes from a CSS counter, so
516/// adding or removing a panel never needs a hand-maintained index.
517fn sec_open(h: &mut String, id: &str, title: &str, hint: &str) {
518    let _ = write!(
519        h,
520        r#"<section id="{id}" class="rise"><div class="sec-head"><h2>{}</h2>
521<span class="hint">{}</span></div>"#,
522        esc(title),
523        esc(hint),
524    );
525}
526
527fn masthead(h: &mut String, rep: &Report) {
528    let l = rep.lang;
529    let _ = write!(
530        h,
531        r#"<header class="mast"><div class="wrap">
532<div class="brand"><span class="mark">V</span><span class="name">llm-verify</span>
533<span class="ver">v{ver}</span><span class="stamp">{started}</span></div>
534<div class="target">
535<div><b>{k_ep}</b><span>{base}</span></div>
536<div><b>{k_model}</b><span>{model}</span></div>
537<div><b>{k_proto}</b><span>{proto}</span></div>
538<div><b>{k_depth}</b><span>{depth}</span></div>
539</div></div></header>"#,
540        ver = esc(&rep.tool_version),
541        started = esc(&rep.started_at),
542        k_ep = ts!(l, "Endpoint", "端点"),
543        k_model = ts!(l, "Model", "模型"),
544        k_proto = ts!(l, "Protocol", "协议"),
545        k_depth = ts!(l, "Depth", "深度"),
546        base = esc(&rep.base_url),
547        model = esc(&rep.model),
548        proto = rep.protocol,
549        depth = esc(&rep.depth),
550    );
551}
552
553fn hero(h: &mut String, rep: &Report) {
554    let l = rep.lang;
555    let v = &rep.verdict;
556    // r=65 ring on a 170px box; circumference drives the draw animation.
557    const R: f64 = 65.0;
558    let circ = 2.0 * std::f64::consts::PI * R;
559    let final_offset = circ * (1.0 - (v.score / 100.0).clamp(0.0, 1.0));
560
561    let _ = write!(
562        h,
563        r#"<div class="hero">
564<div class="donut">
565<svg width="170" height="170" viewBox="0 0 170 170" role="img" aria-label="{aria}">
566<circle class="donut-track" cx="85" cy="85" r="{r}"></circle>
567<circle class="donut-ring" cx="85" cy="85" r="{r}"
568        style="--circ:{circ:.2};--final:{fin:.2};stroke:{col}"></circle>
569</svg>
570<div class="donut-label"><div class="n">{score:.0}</div><div class="u">{k_score}</div></div>
571</div>
572<div class="hero-body">
573<span class="verdict-chip {vcss}"><span class="dot"></span>{vlabel}</span>
574<h1>{host}</h1>
575<p>{vdesc}</p>
576<div class="axes">
577<div><div class="k">{k_origin}</div><div class="v">{chan}</div></div>
578<div><div class="k">{k_ident}</div><div class="v">{ident}</div></div>
579<div><div class="k">{k_conf}</div><div class="v">{conf:.0}%</div></div>
580<div><div class="k">{k_reqs}</div><div class="v">{reqs}</div></div>
581</div>"#,
582        aria = esc(&t!(
583            l,
584            "Overall score {score:.1} out of 100",
585            "综合评分 {score:.1} 分",
586            score = v.score
587        )),
588        r = R,
589        circ = circ,
590        fin = final_offset,
591        col = score_colour(v.score),
592        score = v.score,
593        k_score = esc(ts!(l, "of 100", "满分 100")),
594        vcss = v.authenticity.css(),
595        vlabel = esc(v.authenticity.label(l)),
596        host = esc(&rep.host),
597        vdesc = esc(v.authenticity.desc(l)),
598        k_origin = ts!(l, "Origin", "来源"),
599        chan = esc(v.channel.label(l)),
600        k_ident = ts!(l, "Identity", "身份"),
601        ident = esc(rep.identity.status.label(l)),
602        k_conf = ts!(l, "Confidence", "置信度"),
603        conf = v.confidence * 100.0,
604        k_reqs = ts!(l, "Requests", "请求"),
605        reqs = esc(&t!(
606            l,
607            "{} · {:.1}s",
608            "{} 次 · {:.1}s",
609            rep.request_count,
610            rep.duration_ms as f64 / 1000.0
611        )),
612    );
613
614    tally(h, rep);
615    let _ = write!(h, "</div></div>");
616}
617
618/// One segmented bar for the whole probe run. The proportions carry more at a
619/// glance than four separate counts do.
620fn tally(h: &mut String, rep: &Report) {
621    let l = rep.lang;
622    let total = rep.results.len();
623    if total == 0 {
624        return;
625    }
626    let bands = [
627        (
628            Status::Pass,
629            "var(--pass)",
630            ts!(l, "pass", "通过"),
631            rep.count(Status::Pass),
632        ),
633        (
634            Status::Warn,
635            "var(--warn)",
636            ts!(l, "warn", "警告"),
637            rep.count(Status::Warn),
638        ),
639        (
640            Status::Fail,
641            "var(--fail)",
642            ts!(l, "fail", "失败"),
643            rep.count(Status::Fail),
644        ),
645        (
646            Status::Skip,
647            "var(--skip)",
648            ts!(l, "skip", "跳过"),
649            rep.count(Status::Skip) + rep.count(Status::Error),
650        ),
651    ];
652
653    let _ = write!(h, r#"<div class="tally"><div class="tally-bar">"#);
654    for (_, colour, label, n) in bands {
655        if n == 0 {
656            continue;
657        }
658        let _ = write!(
659            h,
660            r#"<span class="tally-seg" style="--w:{w:.2}%;background:{colour}" title="{label} {n}"></span>"#,
661            w = n as f64 / total as f64 * 100.0,
662            colour = colour,
663            label = esc(label),
664            n = n,
665        );
666    }
667    let _ = write!(h, r#"</div><div class="tally-keys">"#);
668    for (_, colour, label, n) in bands {
669        let _ = write!(
670            h,
671            r#"<span><i style="background:{colour}"></i><b>{n}</b> {label}</span>"#,
672            colour = colour,
673            n = n,
674            label = esc(label),
675        );
676    }
677    let _ = write!(
678        h,
679        r#"<span>{}</span></div></div>"#,
680        esc(&t!(l, "{} probes total", "共 {} 项探针", total))
681    );
682}
683
684fn gates(h: &mut String, rep: &Report) {
685    let l = rep.lang;
686    let gates = &rep.verdict.hard_gate_hits;
687    if gates.is_empty() {
688        return;
689    }
690    let _ = write!(
691        h,
692        r#"<div class="gates rise"><header>⛔ {}</header><ul>"#,
693        esc(&t!(
694            l,
695            "{n} hard gate(s) tripped — no weighted score can excuse these",
696            "硬门禁命中 {n} 项 —— 加权分再高也不能忽略",
697            n = gates.len()
698        ))
699    );
700    for g in gates {
701        let _ = write!(
702            h,
703            r#"<li><b>{}</b> — {}<br><span class="src">{}</span></li>"#,
704            esc(&g.name),
705            esc(&g.reason),
706            esc(&t!(l, "from probe {}", "来自探针 {}", g.probe))
707        );
708    }
709    let _ = write!(h, "</ul></div>");
710}
711
712fn key_stats(h: &mut String, rep: &Report) {
713    let l = rep.lang;
714    let b = &rep.billing;
715    let p = &rep.perf;
716    sec_open(
717        h,
718        "numbers",
719        ts!(l, "Key numbers", "关键指标"),
720        ts!(
721            l,
722            "The state of this endpoint at a glance",
723            "一眼看懂这个端点当前的状态"
724        ),
725    );
726    let _ = write!(h, r#"<div class="grid g4">"#);
727
728    // Billing ratio.
729    let (ratio_txt, ratio_cls, ratio_sub) = if b.honest_input > 0 {
730        let cls = if b.input_ratio > 1.5 {
731            "bad"
732        } else if b.input_ratio > 1.15 {
733            "warn"
734        } else {
735            "good"
736        };
737        (
738            format!("{:.2}x", b.input_ratio),
739            cls,
740            t!(
741                l,
742                "billed {} / actual {} tokens",
743                "计费 {} / 实测 {} token",
744                b.billed_input,
745                b.honest_input
746            ),
747        )
748    } else {
749        (
750            "—".to_string(),
751            "",
752            t!(
753                l,
754                "no independent count to compare against",
755                "没有可对照的独立计数"
756            ),
757        )
758    };
759    stat_tile(
760        h,
761        ts!(l, "Billing ratio", "计费倍率"),
762        &ratio_txt,
763        ratio_cls,
764        &ratio_sub,
765    );
766
767    let ttft = if p.ttft_p50 > 0.0 {
768        (
769            format!("{:.0}ms", p.ttft_p50),
770            if p.ttft_p50 <= 1000.0 {
771                "good"
772            } else if p.ttft_p50 <= 3000.0 {
773                "warn"
774            } else {
775                "bad"
776            },
777            t!(
778                l,
779                "P95 {:.0}ms · {} samples",
780                "P95 {:.0}ms · {} 个样本",
781                p.ttft_p95,
782                p.ttft_ms.len()
783            ),
784        )
785    } else {
786        ("—".into(), "", t!(l, "no streamed samples", "没有流式样本"))
787    };
788    stat_tile(
789        h,
790        ts!(l, "TTFT P50", "首字延迟 P50"),
791        &ttft.0,
792        ttft.1,
793        &ttft.2,
794    );
795
796    let tps = if p.tps_mean > 0.0 {
797        (
798            format!("{:.0}", p.tps_mean),
799            "",
800            format!(
801                "tok/s · {}",
802                match crate::probes::perf::tier_band(p.tps_mean) {
803                    "large" => t!(l, "large-model speed", "偏大模型速度"),
804                    "small" => t!(l, "small-model speed", "偏小模型速度"),
805                    _ => t!(l, "in between", "中间档"),
806                }
807            ),
808        )
809    } else {
810        ("—".into(), "", "tok/s".into())
811    };
812    stat_tile(h, ts!(l, "Throughput", "生成吞吐"), &tps.0, tps.1, &tps.2);
813
814    let cov = (1.0 - rep.verdict.coverage_gap) * 100.0;
815    stat_tile(
816        h,
817        ts!(l, "Probe coverage", "探针覆盖率"),
818        &format!("{cov:.0}%"),
819        stat_class(cov),
820        &t!(l, "{} did not run", "{} 项未执行", rep.skipped.len()),
821    );
822
823    let _ = write!(h, "</div></section>");
824}
825
826fn stat_tile(h: &mut String, label: &str, value: &str, cls: &str, sub: &str) {
827    let _ = write!(
828        h,
829        r#"<div class="card stat {cls}"><h3>{label}</h3><div class="v {cls}">{value}</div>
830<div class="s">{sub}</div></div>"#,
831        cls = cls,
832        label = esc(label),
833        value = esc(value),
834        sub = esc(sub),
835    );
836}
837
838fn group_scores(h: &mut String, rep: &Report) {
839    let l = rep.lang;
840    sec_open(
841        h,
842        "groups",
843        ts!(l, "Scores by group", "分组得分"),
844        ts!(
845            l,
846            "Locate which layer the problems are in",
847            "问题出在哪一层,一眼定位"
848        ),
849    );
850    let _ = write!(h, r#"<div class="card"><div class="bars">"#);
851    for g in Group::ALL {
852        let Some(pct) = rep.verdict.group_scores.get(g.key()) else {
853            continue;
854        };
855        let _ = write!(
856            h,
857            r#"<div class="bar-row"><div class="lbl" title="{hint}">{label}</div>
858<div class="bar-track"><div class="bar-fill" style="--w:{pct:.1}%;background:{col}"></div></div>
859<div class="num">{pct:.1}</div></div>"#,
860            hint = esc(g.blurb(l)),
861            label = esc(g.label(l)),
862            pct = pct,
863            col = score_gradient(*pct),
864        );
865    }
866    let _ = write!(h, "</div></div></section>");
867}
868
869fn identity_panel(h: &mut String, rep: &Report) {
870    let l = rep.lang;
871    let id = &rep.identity;
872    sec_open(
873        h,
874        "identity",
875        ts!(l, "Model identity", "模型身份"),
876        ts!(
877            l,
878            "Is the model behind this the one that was sold?",
879            "背后跑的是不是它声称的那个模型"
880        ),
881    );
882    let _ = write!(h, r#"<div class="grid g2">"#);
883
884    // Claim vs observation.
885    let _ = write!(
886        h,
887        r#"<div class="card"><h3>{title}</h3>
888<div class="dl">
889<div class="dl-row"><span class="k">{k1}</span><span class="v">{claimed}</span></div>
890<div class="dl-row"><span class="k">{k2}</span><span class="v">{cf} / {ct}</span></div>
891<div class="dl-row"><span class="k">{k3}</span><span class="v">{of}</span></div>
892<div class="dl-row"><span class="k">{k4}</span><span class="v">{et}</span></div>
893<div class="dl-row"><span class="k">{k5}</span><span class="v">{basis}</span></div>
894</div>
895<div class="ratio-note {ncls}">{istatus}</div>{thin}
896</div>"#,
897        title = esc(ts!(l, "Claimed vs measured", "宣称 vs 实测")),
898        k1 = esc(ts!(l, "Claimed model", "宣称模型")),
899        claimed = esc(&id.claimed_model),
900        k2 = esc(ts!(l, "Claimed family / tier", "宣称家族 / 档位")),
901        cf = esc(id
902            .claimed_family
903            .as_deref()
904            .unwrap_or(ts!(l, "unknown", "未知"))),
905        ct = esc(id
906            .claimed_tier
907            .as_deref()
908            .unwrap_or(ts!(l, "unknown", "未知"))),
909        k3 = esc(ts!(l, "Measured family", "实测家族")),
910        of = esc(&t!(
911            l,
912            "{} (confidence {:.0}%)",
913            "{}(信心 {:.0}%)",
914            id.observed_family
915                .as_deref()
916                .unwrap_or(ts!(l, "unidentified", "未识别")),
917            id.family_confidence * 100.0
918        )),
919        k4 = esc(ts!(l, "Measured tier", "实测档位")),
920        et = esc(&t!(
921            l,
922            "{} (fit {:.2})",
923            "{}(拟合 {:.2})",
924            id.estimated_tier
925                .as_deref()
926                .unwrap_or(ts!(l, "not measured", "未测出")),
927            id.tier_confidence
928        )),
929        k5 = esc(ts!(l, "Tier verdict rests on", "档位判定依据")),
930        basis = esc(&t!(
931            l,
932            "{} questions · beat runner-up by {:.3}",
933            "{} 道能力题 · 领先次优 {:.3}",
934            id.tier_questions,
935            id.tier_margin
936        )),
937        ncls = id.status.css(),
938        istatus = esc(id.status.label(l)),
939        thin = if id.tier_questions > 0 && id.tier_questions < 9 {
940            format!(
941                r#"<div class="foot-note">{}</div>"#,
942                t!(
943                    l,
944                    "The tier call rests on a thin sample; adjacent tiers can swing \
945                     on a single question. Re-run with <code>--depth forensic</code> \
946                     for a verdict that holds up.",
947                    "档位判定的样本量偏小,相邻档位之间可能因单题得失而摆动。需要更有把握的结论请用 <code>--depth forensic</code> 重跑。"
948                )
949            )
950        } else {
951            String::new()
952        },
953    );
954
955    // Capability profile.
956    let _ = write!(
957        h,
958        r#"<div class="card"><h3>{}</h3><div class="bars">"#,
959        esc(ts!(l, "Tier hypothesis fit", "能力档位拟合"))
960    );
961    for (tier, label) in [
962        ("large", ts!(l, "flagship", "旗舰档")),
963        ("mid", ts!(l, "mid", "中档")),
964        ("small", ts!(l, "light", "轻量档")),
965    ] {
966        let score = id.tier_scores.get(tier).copied().unwrap_or(0.0);
967        let is_best = id.estimated_tier.as_deref() == Some(tier);
968        let _ = write!(
969            h,
970            r#"<div class="bar-row"><div class="lbl">{label}{mark}</div>
971<div class="bar-track"><div class="bar-fill" style="--w:{w:.1}%;background:{col}"></div></div>
972<div class="num">{score:.2}</div></div>"#,
973            label = esc(label),
974            mark = if is_best { " ●" } else { "" },
975            w = score * 100.0,
976            col = if is_best {
977                "linear-gradient(90deg,var(--accent),var(--accent-2))"
978            } else {
979                "var(--rule-2)"
980            },
981            score = score,
982        );
983    }
984    let _ = write!(h, "</div>");
985
986    if !id.accuracy_by_difficulty.is_empty() {
987        let _ = write!(
988            h,
989            r#"<h3 style="margin-top:18px">{}</h3><div class="bars">"#,
990            esc(ts!(l, "Accuracy by difficulty", "分难度正确率"))
991        );
992        for (key, label) in [
993            ("easy", ts!(l, "easy", "简单")),
994            ("medium", ts!(l, "medium", "中等")),
995            ("hard", ts!(l, "hard", "困难")),
996        ] {
997            let acc = id.accuracy_by_difficulty.get(key).copied().unwrap_or(0.0);
998            let _ = write!(
999                h,
1000                r#"<div class="bar-row"><div class="lbl">{label}</div>
1001<div class="bar-track"><div class="bar-fill" style="--w:{w:.0}%;background:{col}"></div></div>
1002<div class="num">{w:.0}%</div></div>"#,
1003                label = esc(label),
1004                w = acc * 100.0,
1005                col = score_gradient(acc * 100.0),
1006            );
1007        }
1008        let _ = write!(h, "</div>");
1009    }
1010    let _ = write!(h, "</div>");
1011
1012    if !id.evidence.is_empty() {
1013        let _ = write!(
1014            h,
1015            r#"<div class="card span-all"><h3>{}</h3><ul class="ev">"#,
1016            esc(ts!(l, "Identity evidence", "身份证据"))
1017        );
1018        for e in &id.evidence {
1019            let _ = write!(h, "<li>{}</li>", esc(e));
1020        }
1021        let _ = write!(h, "</ul></div>");
1022    }
1023    let _ = write!(h, "</div></section>");
1024}
1025
1026fn billing_panel(h: &mut String, rep: &Report) {
1027    let l = rep.lang;
1028    let b = &rep.billing;
1029    sec_open(
1030        h,
1031        "billing",
1032        ts!(l, "Metering & billing", "计量与计费"),
1033        ts!(
1034            l,
1035            "Are the token counts honest, or are you overcharged?",
1036            "计费数字可信吗,有没有多收钱"
1037        ),
1038    );
1039    let _ = write!(h, r#"<div class="grid g2">"#);
1040
1041    // Billed vs honest bars, scaled to whichever is larger.
1042    let max = b.billed_input.max(b.honest_input).max(1) as f64;
1043    let _ = write!(
1044        h,
1045        r#"<div class="card"><h3>{title}</h3><div class="cmp">
1046<div class="cmp-row"><div class="top"><span class="k">{k1}</span><span class="n">{billed}</span></div>
1047<div class="bar-track"><div class="bar-fill" style="--w:{bw:.1}%;background:{bc}"></div></div></div>
1048<div class="cmp-row"><div class="top"><span class="k">{k2}</span><span class="n">{honest}</span></div>
1049<div class="bar-track"><div class="bar-fill" style="--w:{hw:.1}%;background:linear-gradient(90deg,var(--accent),var(--accent-2))"></div></div></div>
1050</div>
1051<div class="ratio-note {rcls}">{rtext}</div>
1052<div class="foot-note">{method}</div>
1053</div>"#,
1054        title = esc(ts!(
1055            l,
1056            "Input tokens: billed vs independent recount",
1057            "输入 token:计费 vs 独立重算"
1058        )),
1059        k1 = esc(ts!(l, "Endpoint billed", "端点计费")),
1060        billed = b.billed_input,
1061        k2 = esc(ts!(l, "Independent recount", "独立重算")),
1062        honest = b.honest_input,
1063        bw = b.billed_input as f64 / max * 100.0,
1064        hw = b.honest_input as f64 / max * 100.0,
1065        bc = if b.input_ratio > 1.15 {
1066            "linear-gradient(90deg,var(--fail),var(--fail-2))"
1067        } else {
1068            "linear-gradient(90deg,var(--pass),var(--pass-2))"
1069        },
1070        rcls = if b.input_ratio > 1.5 {
1071            "ratio-bad"
1072        } else if b.input_ratio > 1.15 {
1073            "ratio-hi"
1074        } else {
1075            "ratio-ok"
1076        },
1077        rtext = esc(&if b.honest_input == 0 {
1078            t!(
1079                l,
1080                "No independent count to compare against, so no ratio can be given",
1081                "没有可对照的独立计数,无法给出倍率"
1082            )
1083        } else if b.input_ratio > 1.15 {
1084            t!(
1085                l,
1086                "Billing ratio {:.2}x — roughly {:.0}% more than actual",
1087                "计费倍率 {:.2}×——比实际多算了约 {:.0}%",
1088                b.input_ratio,
1089                (b.input_ratio - 1.0) * 100.0
1090            )
1091        } else {
1092            t!(
1093                l,
1094                "Billing ratio {:.2}x, within the normal range",
1095                "计费倍率 {:.2}×,在正常范围内",
1096                b.input_ratio
1097            )
1098        }),
1099        method = esc(&t!(l, "Compared against: {}", "对照方式:{}", b.method)),
1100    );
1101
1102    // Cost, or an explicit statement that no price was applied.
1103    let _ = write!(
1104        h,
1105        r#"<div class="card"><h3>{}</h3>"#,
1106        esc(ts!(l, "Cost", "成本折算"))
1107    );
1108    if b.billed_cost_usd > 0.0 {
1109        let _ = write!(
1110            h,
1111            r#"<div class="dl">
1112<div class="dl-row"><span class="k">{k1}</span><span class="v">${bc:.6}</span></div>
1113<div class="dl-row"><span class="k">{k2}</span><span class="v">${hc:.6}</span></div>
1114<div class="dl-row"><span class="k">{k3}</span><span class="v">${d:.6}</span></div>
1115</div>
1116<div class="foot-note">{note}</div>"#,
1117            k1 = esc(ts!(l, "At the billed counts", "按计费数字")),
1118            bc = b.billed_cost_usd,
1119            k2 = esc(ts!(l, "At the measured counts", "按实测数字")),
1120            hc = b.honest_cost_usd,
1121            k3 = esc(ts!(l, "Difference", "差额")),
1122            d = b.billed_cost_usd - b.honest_cost_usd,
1123            // The source is a noun phrase ("built-in price table"), so it needs
1124            // a carrier sentence around it rather than being dropped in front
1125            // of a full stop.
1126            note = esc(&t!(
1127                l,
1128                "Priced from the {}. These figures cover only this run's few probe \
1129                 requests — they illustrate the ratio, they are not a monthly estimate.",
1130                "按{}折算。金额只是本次几个探测请求的量级,用于说明倍率,不是月账单预估。",
1131                b.pricing_source
1132            )),
1133        );
1134    } else {
1135        let _ = write!(
1136            h,
1137            r#"<div class="note warnbox">{}</div>"#,
1138            esc(&b.pricing_source)
1139        );
1140    }
1141    if !b.anomalies.is_empty() {
1142        let _ = write!(
1143            h,
1144            r#"<h3 style="margin-top:18px">{}</h3><ul class="ev">"#,
1145            esc(ts!(l, "Metering anomalies", "计量异常"))
1146        );
1147        for a in &b.anomalies {
1148            let _ = write!(h, "<li>{}</li>", esc(a));
1149        }
1150        let _ = write!(h, "</ul>");
1151    }
1152    let _ = write!(h, "</div></div></section>");
1153}
1154
1155fn perf_panel(h: &mut String, rep: &Report) {
1156    let l = rep.lang;
1157    let p = &rep.perf;
1158    if p.samples == 0 {
1159        return;
1160    }
1161    sec_open(
1162        h,
1163        "perf",
1164        ts!(l, "Performance", "性能"),
1165        ts!(
1166            l,
1167            "Latency, throughput and jitter — also identity evidence",
1168            "首字延迟、吞吐与抖动,也是身份旁证"
1169        ),
1170    );
1171    let _ = write!(h, r#"<div class="grid g2">"#);
1172
1173    dist_chart(
1174        h,
1175        ts!(l, "Time to first token (ms)", "首字延迟分布(ms)"),
1176        &p.ttft_ms,
1177        "ms",
1178        l,
1179    );
1180    dist_chart(
1181        h,
1182        ts!(l, "End-to-end latency (ms)", "端到端延迟分布(ms)"),
1183        &p.latency_ms,
1184        "ms",
1185        l,
1186    );
1187
1188    let _ = write!(
1189        h,
1190        r#"<div class="card span-all"><h3>{}</h3><div class="grid g4" style="gap:14px">"#,
1191        esc(ts!(l, "Summary", "汇总"))
1192    );
1193    for (label, value) in [
1194        ("TTFT P50".to_string(), format!("{:.0} ms", p.ttft_p50)),
1195        ("TTFT P95".to_string(), format!("{:.0} ms", p.ttft_p95)),
1196        (
1197            t!(l, "Latency P50", "延迟 P50"),
1198            format!("{:.0} ms", p.latency_p50),
1199        ),
1200        (
1201            t!(l, "Latency P95", "延迟 P95"),
1202            format!("{:.0} ms", p.latency_p95),
1203        ),
1204        (
1205            t!(l, "Mean throughput", "平均吞吐"),
1206            format!("{:.1} tok/s", p.tps_mean),
1207        ),
1208        (
1209            t!(l, "Coefficient of variation", "变异系数"),
1210            format!("{:.2}", p.latency_cv),
1211        ),
1212        (t!(l, "Samples", "采样数"), format!("{}", p.samples)),
1213    ] {
1214        let _ = write!(
1215            h,
1216            r#"<div><div style="font-size:10px;letter-spacing:.11em;text-transform:uppercase;color:var(--muted);font-family:var(--mono)">{}</div>
1217<div style="font-family:var(--mono);font-size:18px;font-variant-numeric:tabular-nums;letter-spacing:-.02em;margin-top:2px">{}</div></div>"#,
1218            esc(&label),
1219            esc(&value)
1220        );
1221    }
1222    if p.latency_cv > 0.5 {
1223        let _ = write!(
1224            h,
1225            r#"</div><div class="ratio-note ratio-hi">{}</div>"#,
1226            esc(&t!(
1227                l,
1228                "A coefficient of variation of {:.2} is high: a scattered latency \
1229                 distribution on one endpoint is typical of round-robin across \
1230                 several providers, or heavy oversubscription.",
1231                "变异系数 {:.2} 偏高:同一端点的耗时分布分散,常见于后端轮询多个供应商或严重超卖。",
1232                p.latency_cv
1233            ))
1234        );
1235    } else {
1236        let _ = write!(h, "</div>");
1237    }
1238    let _ = write!(h, "</div></div></section>");
1239}
1240
1241fn dist_chart(h: &mut String, title: &str, values: &[f64], unit: &str, l: Lang) {
1242    if values.is_empty() {
1243        return;
1244    }
1245    let max = values.iter().cloned().fold(0.0_f64, f64::max).max(1.0);
1246    let p50 = percentile(values, 0.5);
1247    let _ = write!(
1248        h,
1249        r#"<div class="card"><h3>{}</h3><div class="dist">"#,
1250        esc(title)
1251    );
1252    for (i, v) in values.iter().enumerate() {
1253        // Floor the height so a genuinely fast sample is still a visible bar
1254        // rather than an empty column.
1255        let pct = (v / max * 100.0).max(4.0);
1256        let _ = write!(
1257            h,
1258            r#"<div class="dist-col" title="{tip}"><div class="dist-bar" style="--h:{pct:.1}%;animation-delay:{d:.2}s"></div></div>"#,
1259            tip = esc(&format!("#{} · {v:.0}{unit}", i + 1)),
1260            pct = pct,
1261            d = 0.3 + i as f64 * 0.05,
1262        );
1263    }
1264    // The median line turns a wall of bars into a readable distribution: how
1265    // far the tail sits above the typical sample is the whole point.
1266    let _ = write!(
1267        h,
1268        r#"<div class="dist-mark" style="bottom:{mark:.1}%"><span>P50 {p50:.0}{unit}</span></div>"#,
1269        // The plot area is the chart box minus its top padding, and a
1270        // percentage `bottom` resolves against the full box — hence the ratio.
1271        mark = (p50 / max * 100.0).clamp(4.0, 100.0) * DIST_PLOT_RATIO,
1272        p50 = p50,
1273        unit = unit,
1274    );
1275    let _ = write!(
1276        h,
1277        r#"</div><div class="dist-axis"><span>{lo}</span><span>{n}</span><span>{hi}</span></div></div>"#,
1278        lo = esc(&t!(
1279            l,
1280            "min {:.0}{unit}",
1281            "最小 {:.0}{unit}",
1282            values.iter().cloned().fold(f64::INFINITY, f64::min)
1283        )),
1284        n = esc(&t!(l, "{} samples", "{} 个样本", values.len())),
1285        hi = esc(&t!(l, "max {max:.0}{unit}", "最大 {max:.0}{unit}")),
1286    );
1287}
1288
1289fn channel_panel(h: &mut String, rep: &Report) {
1290    let l = rep.lang;
1291    let c = &rep.channel;
1292    sec_open(
1293        h,
1294        "channel",
1295        ts!(l, "Channel provenance", "渠道来源"),
1296        ts!(
1297            l,
1298            "What the request passes through before it reaches the model",
1299            "请求到达模型前经过了什么"
1300        ),
1301    );
1302    let _ = write!(
1303        h,
1304        r#"<div class="card"><ol class="chain"><li class="hop you">{}</li>"#,
1305        esc(ts!(l, "your request", "你的请求"))
1306    );
1307    if c.all_hops.is_empty() {
1308        let _ = write!(
1309            h,
1310            r#"<li class="arrow">→</li><li class="hop">{}</li>"#,
1311            esc(&c.display)
1312        );
1313    } else {
1314        for hop in &c.all_hops {
1315            let _ = write!(
1316                h,
1317                r#"<li class="arrow">→</li><li class="hop">{}</li>"#,
1318                esc(hop)
1319            );
1320        }
1321    }
1322    let _ = write!(
1323        h,
1324        r#"<li class="arrow">→</li><li class="hop you">{}</li></ol>"#,
1325        esc(ts!(l, "model", "模型"))
1326    );
1327
1328    let _ = write!(
1329        h,
1330        r#"<div class="dl">
1331<div class="dl-row"><span class="k">{k1}</span><span class="v">{ident}</span></div>
1332<div class="dl-row"><span class="k">{k2}</span><span class="v">{chan}</span></div></div>
1333<div class="foot-note" style="color:var(--ink-2);font-size:13px">{desc}</div>"#,
1334        k1 = esc(ts!(l, "Classified as", "识别结果")),
1335        ident = esc(&t!(
1336            l,
1337            "{} (tier-{} signal, {:.0}% confidence)",
1338            "{}({} 层信号,置信度 {:.0}%)",
1339            c.display,
1340            c.tier,
1341            c.confidence * 100.0
1342        )),
1343        k2 = esc(ts!(l, "Origin category", "来源归类")),
1344        chan = esc(rep.verdict.channel.label(l)),
1345        desc = esc(rep.verdict.channel.desc(l)),
1346    );
1347
1348    if !c.evidence.is_empty() {
1349        let _ = write!(
1350            h,
1351            r#"<h3 style="margin-top:18px">{}</h3><ul class="ev">"#,
1352            esc(ts!(l, "Evidence", "识别依据"))
1353        );
1354        for e in &c.evidence {
1355            let _ = write!(h, "<li>{}</li>", esc(e));
1356        }
1357        let _ = write!(h, "</ul>");
1358    }
1359    let _ = write!(h, "</div></section>");
1360}
1361
1362fn probe_table(h: &mut String, rep: &Report) {
1363    let l = rep.lang;
1364    sec_open(
1365        h,
1366        "probes",
1367        ts!(l, "Probe detail", "探针明细"),
1368        &t!(
1369            l,
1370            "{n} probes; expand any row for evidence and the raw response",
1371            "{n} 项,点开可看证据与原始响应",
1372            n = rep.results.len()
1373        ),
1374    );
1375    let _ = write!(
1376        h,
1377        r#"<div class="tbl-wrap"><table>
1378<thead><tr><th>{c1}</th><th>ID</th><th>{c2}</th><th>{c3}</th><th style="text-align:right">{c4}</th></tr></thead>
1379<tbody>"#,
1380        c1 = esc(ts!(l, "Status", "状态")),
1381        c2 = esc(ts!(l, "Probe", "探针")),
1382        c3 = esc(ts!(l, "Conclusion", "结论")),
1383        c4 = esc(ts!(l, "Took", "耗时")),
1384    );
1385
1386    for g in Group::ALL {
1387        let rows = rep.by_group(g);
1388        if rows.is_empty() {
1389            continue;
1390        }
1391        // A per-group tally on the group row saves scanning every line to see
1392        // where a group lost its points.
1393        let pass = rows.iter().filter(|r| r.status == Status::Pass).count();
1394        let _ = write!(
1395            h,
1396            r#"<tr class="grp"><td colspan="5"><div class="grp-head"><b>{}</b><span>{}</span>
1397<em>{}</em></div></td></tr>"#,
1398            esc(g.label(l)),
1399            esc(g.blurb(l)),
1400            esc(&t!(l, "{}/{} passed", "{}/{} 通过", pass, rows.len())),
1401        );
1402        for r in rows {
1403            let _ = write!(
1404                h,
1405                r#"<tr><td class="st"><span class="tag {cls}">{sym} {slabel}</span></td>
1406<td class="pid">{id}</td><td class="plabel">{label}{neutral}</td><td class="psum">{summary}"#,
1407                cls = r.status.css(),
1408                sym = r.status.symbol(),
1409                slabel = esc(r.status.label(l)),
1410                id = esc(&r.id),
1411                label = esc(&r.label),
1412                neutral = if r.neutral {
1413                    format!(
1414                        r#"<br><span class="neutral-note">{}</span>"#,
1415                        esc(ts!(l, "evidence only, not scored", "仅取证,不计分"))
1416                    )
1417                } else {
1418                    String::new()
1419                },
1420                summary = esc(&r.summary),
1421            );
1422
1423            let has_detail =
1424                !r.findings.is_empty() || r.evidence.is_some() || !r.metrics.is_empty();
1425            if has_detail {
1426                let _ = write!(
1427                    h,
1428                    r#"<details class="detail"><summary>{}</summary><div class="detail-body">"#,
1429                    esc(ts!(l, "detail", "详情"))
1430                );
1431                if !r.findings.is_empty() {
1432                    let _ = write!(h, "<ul>");
1433                    for f in &r.findings {
1434                        let _ = write!(h, "<li>{}</li>", esc(f));
1435                    }
1436                    let _ = write!(h, "</ul>");
1437                }
1438                if !r.metrics.is_empty() {
1439                    let _ = write!(h, r#"<div class="kv">"#);
1440                    for (k, val) in &r.metrics {
1441                        let s = match val {
1442                            serde_json::Value::String(s) => s.clone(),
1443                            other => other.to_string(),
1444                        };
1445                        if s.is_empty() {
1446                            continue;
1447                        }
1448                        let _ = write!(
1449                            h,
1450                            "<code>{}={}</code>",
1451                            esc(k),
1452                            esc(&crate::util::truncate(&s, 60))
1453                        );
1454                    }
1455                    let _ = write!(h, "</div>");
1456                }
1457                if let Some(e) = &r.evidence {
1458                    let _ = write!(h, "<pre>{}</pre>", esc(e));
1459                }
1460                let _ = write!(h, "</div></details>");
1461            }
1462            let _ = write!(h, r#"</td><td class="pms">{}ms</td></tr>"#, r.duration_ms);
1463        }
1464    }
1465    let _ = write!(h, "</tbody></table></div></section>");
1466}
1467
1468fn trace_panel(h: &mut String, rep: &Report) {
1469    let l = rep.lang;
1470    let v = &rep.verdict;
1471    sec_open(
1472        h,
1473        "verdict",
1474        ts!(l, "How the verdict was reached", "判定过程"),
1475        ts!(
1476            l,
1477            "Every step, so you can check it yourself",
1478            "结论怎么来的,可以自己复核"
1479        ),
1480    );
1481    let _ = write!(
1482        h,
1483        r#"<div class="grid g2"><div class="card"><h3>{}</h3><ol class="trace">"#,
1484        esc(ts!(l, "Decision trace", "决策轨迹"))
1485    );
1486    for t in &v.trace {
1487        let _ = write!(h, "<li>{}</li>", esc(t));
1488    }
1489    let _ = write!(h, "</ol></div>");
1490
1491    let _ = write!(
1492        h,
1493        r#"<div class="card"><h3>{}</h3>"#,
1494        esc(ts!(l, "Key signals", "关键信号"))
1495    );
1496    if v.signals.is_empty() {
1497        let _ = write!(
1498            h,
1499            r#"<p style="margin:0;color:var(--muted);font-size:13.5px">{}</p>"#,
1500            esc(ts!(
1501                l,
1502                "No risk signals were triggered.",
1503                "没有触发任何风险信号。"
1504            ))
1505        );
1506    } else {
1507        let _ = write!(h, r#"<ul class="ev">"#);
1508        for s in &v.signals {
1509            let _ = write!(h, "<li>{}</li>", esc(s));
1510        }
1511        let _ = write!(h, "</ul>");
1512    }
1513
1514    if !rep.skipped.is_empty() {
1515        let _ = write!(
1516            h,
1517            r#"<h3 style="margin-top:18px">{title}</h3>
1518<div class="note warnbox" style="margin-bottom:11px;font-size:13px">
1519<b>{warn}</b> {body}</div>
1520<ul class="ev">"#,
1521            title = esc(&t!(
1522                l,
1523                "Probes that did not run ({n})",
1524                "未执行的探针({n} 项)",
1525                n = rep.skipped.len()
1526            )),
1527            warn = esc(ts!(
1528                l,
1529                "Not tested is not the same as passed.",
1530                "未测不等于通过。"
1531            )),
1532            body = esc(ts!(
1533                l,
1534                "The probes below did not complete, so the conclusions they would \
1535                 have supported are outside this report's coverage.",
1536                "下列探针没有跑完,相关结论不在本报告的覆盖范围内。"
1537            )),
1538        );
1539        for s in &rep.skipped {
1540            let _ = write!(h, "<li>{}</li>", esc(s));
1541        }
1542        let _ = write!(h, "</ul>");
1543    }
1544    let _ = write!(h, "</div></div></section>");
1545}
1546
1547fn limits_panel(h: &mut String, l: Lang) {
1548    sec_open(
1549        h,
1550        "limits",
1551        ts!(l, "What this cannot prove", "能力边界"),
1552        ts!(l, "The limits of this report", "这份报告不能证明什么"),
1553    );
1554    let _ = write!(
1555        h,
1556        r#"<div class="note"><b>{lead}</b> {lead2}</div>
1557<ul class="limits" style="margin-top:16px">"#,
1558        lead = esc(ts!(
1559            l,
1560            "One false accusation against an honest provider costs far more than one miss.",
1561            "对诚实供应商的一次误判,代价远高于一次漏检。"
1562        )),
1563        lead2 = esc(ts!(
1564            l,
1565            "This tool abstains when the evidence is thin rather than guessing. \
1566             Please read the conclusions alongside the limits below.",
1567            "本工具在证据不足时一律弃权,而不是猜一个结论。读报告时请一并考虑下面几条限制。"
1568        )),
1569    );
1570
1571    for (head, body) in [
1572        (
1573            ts!(l, "Adjacent versions are indistinguishable.", "同家族相邻版本不可分。"),
1574            ts!(
1575                l,
1576                "This tool resolves to tier granularity only (flagship / mid / light). \
1577                 Adjacent versions inside one tier — say 4.5 and 4.6 of the same line — \
1578                 cannot be separated without distribution baselines, so the report \
1579                 stops at the tier.",
1580                "本工具只做到「档位」粒度(旗舰 / 中档 / 轻量),同档位内的相邻版本(例如同系列的 4.5 与 4.6)在没有分布基线时无法区分,报告只会给出档位结论。"
1581            ),
1582        ),
1583        (
1584            ts!(l, "The tier call depends on sampling.", "档位判定依赖采样。"),
1585            ts!(
1586                l,
1587                "Adjacent tiers can still swing on a single question. The tool abstains \
1588                 when the margin is narrow, which costs it some real downgrades. \
1589                 Use <code>--depth forensic</code> when the answer has to hold up.",
1590                "相邻档位之间仍可能因单题得失而摆动。工具在差距不明显时会主动弃权,代价是可能漏掉真实的降级。需要拿得出手的结论请用 <code>--depth forensic</code>。"
1591            ),
1592        ),
1593        (
1594            ts!(l, "Middle layers contaminate identity fingerprints.", "中间层会污染身份指纹。"),
1595            ts!(
1596                l,
1597                "An injected system prompt and any response post-processing both change \
1598                 how a model expresses itself, which is why the contract layer runs \
1599                 first — where injection or rewriting is found, identity confidence has \
1600                 already been reduced accordingly.",
1601                "注入的 system prompt 与响应后处理都会改变模型的表达风格,所以协议契约层必须先跑;一旦发现注入或改写,身份结论的置信度已相应下调。"
1602            ),
1603        ),
1604        (
1605            ts!(l, "Server-side weights cannot be proven.", "无法证明服务端权重就是官方权重。"),
1606            ts!(
1607                l,
1608                "What this tool can show is whether behaviour matches expectations. It \
1609                 cannot show which model file the other side actually deployed.",
1610                "本工具能证明的是「行为与预期一致或不一致」,不能证明对方部署的是不是原始模型文件。"
1611            ),
1612        ),
1613        (
1614            ts!(l, "Quantised builds are hard to spot.", "量化版本难以识别。"),
1615            ts!(
1616                l,
1617                "int4 and fp8 builds sit close to the original in capability, so only a \
1618                 probabilistic read from the tier estimate is possible — never a verdict.",
1619                "int4 / fp8 量化后的模型与原版在能力上差距较小,只能通过能力档位给出概率性判断,给不了定论。"
1620            ),
1621        ),
1622        (
1623            ts!(l, "One run describes one moment.", "一次检测只代表此刻。"),
1624            ts!(
1625                l,
1626                "Gradual degradation only shows up under continuous monitoring. Re-run \
1627                 periodically and compare against earlier reports.",
1628                "渐进式降级需要持续监测才能发现,建议定期重跑并对比历史报告。"
1629            ),
1630        ),
1631        (
1632            ts!(l, "An estimate is not an authoritative count.", "估算与权威计数不同。"),
1633            ts!(
1634                l,
1635                "The metering comparison is only authoritative where the endpoint offers \
1636                 count_tokens. Otherwise the report uses a local estimate, and says so \
1637                 under \"compared against\".",
1638                "只有端点提供 count_tokens 时计量对照才是权威的;否则报告使用本地估算,并已在「对照方式」中注明。"
1639            ),
1640        ),
1641    ] {
1642        let _ = write!(h, "<li><b>{}</b>{}</li>", esc(head), body);
1643    }
1644    let _ = write!(h, "</ul></section>");
1645}
1646
1647fn footer(h: &mut String, rep: &Report) {
1648    let l = rep.lang;
1649    let _ = write!(
1650        h,
1651        r##"<footer class="wrap">
1652<p>{line1}</p>
1653<p>{line2}</p>
1654<p class="disclaimer">{line3}</p>
1655<p><a href="#top">{top}</a></p>
1656</footer>"##,
1657        line1 = esc(&t!(
1658            l,
1659            "llm-verify v{ver} · started {start} · finished {end} · {reqs} requests · {secs:.1}s",
1660            "llm-verify v{ver} · 开始 {start} · 结束 {end} · 共 {reqs} 次请求 · 耗时 {secs:.1}s",
1661            ver = rep.tool_version,
1662            start = rep.started_at,
1663            end = rep.finished_at,
1664            reqs = rep.request_count,
1665            secs = rep.duration_ms as f64 / 1000.0
1666        )),
1667        line2 = esc(&t!(
1668            l,
1669            "target {base} · model {model} · claimed {claimed} · protocol {proto} · depth {depth}",
1670            "目标 {base} · 模型 {model} · 宣称 {claimed} · 协议 {proto} · 深度 {depth}",
1671            base = rep.base_url,
1672            model = rep.model,
1673            claimed = rep.claimed_model,
1674            proto = rep.protocol,
1675            depth = rep.depth
1676        )),
1677        line3 = esc(ts!(
1678            l,
1679            "This is an automated black-box measurement, offered for reference only. \
1680             It is not a legal accusation against any provider.",
1681            "本报告为自动化黑盒检测结果,仅供参考,不构成对任何供应商的法律指控。"
1682        )),
1683        top = esc(ts!(l, "back to top ↑", "回到顶部 ↑")),
1684    );
1685}
1686
1687#[cfg(test)]
1688mod tests {
1689    use super::*;
1690    use crate::protocol::Protocol;
1691    use std::collections::BTreeMap;
1692
1693    /// A report with every panel populated, so the renderer is exercised on
1694    /// the shape a real run produces rather than on `Default::default()`.
1695    fn sample(lang: Lang) -> Report {
1696        let mut group_scores = BTreeMap::new();
1697        for (k, v) in [
1698            ("contract", 96.0),
1699            ("stream", 100.0),
1700            ("billing", 88.0),
1701            ("channel", 54.0),
1702            ("perf", 72.0),
1703            ("identity", 91.0),
1704            ("consistency", 100.0),
1705        ] {
1706            group_scores.insert(k.to_string(), v);
1707        }
1708        let mut tier_scores = BTreeMap::new();
1709        tier_scores.insert("large".into(), 0.82);
1710        tier_scores.insert("mid".into(), 0.61);
1711        tier_scores.insert("small".into(), 0.24);
1712        let mut acc = BTreeMap::new();
1713        acc.insert("easy".into(), 1.0);
1714        acc.insert("medium".into(), 0.83);
1715        acc.insert("hard".into(), 0.5);
1716
1717        let results = vec![
1718            ProbeResult::new("contract.headers", "Response headers", Group::Contract)
1719                .pass("Vendor markers absent, shape otherwise correct")
1720                .metric("server", "cloudflare")
1721                .evidence("HTTP/2 200\nserver: cloudflare")
1722                .took(412),
1723            ProbeResult::new("stream.sse", "SSE framing", Group::Stream)
1724                .pass("Well-formed event stream")
1725                .took(980),
1726            ProbeResult::new("billing.recount", "Independent recount", Group::Billing)
1727                .warn("Billed 6% above the local estimate")
1728                .finding("ratio 1.06 across 3 rounds")
1729                .took(1_240),
1730            ProbeResult::new("channel.hops", "Relay signatures", Group::Channel)
1731                .fail("Two relay signatures on the path")
1732                .took(305),
1733            ProbeResult::new("perf.ttft", "First-token latency", Group::Perf)
1734                .warn("P95 well above P50")
1735                .took(2_100),
1736            ProbeResult::new("identity.tier", "Capability tier", Group::Identity)
1737                .pass("Flagship tier, margin 0.21")
1738                .neutral()
1739                .took(31_500),
1740            ProbeResult::new("consistency.repeat", "Repeat requests", Group::Consistency)
1741                .skip("Endpoint does not expose a seed")
1742                .took(120),
1743        ];
1744
1745        Report {
1746            schema_version: schema_version(),
1747            tool_version: "0.2.2".into(),
1748            lang,
1749            started_at: "2026-08-13 07:24:01".into(),
1750            finished_at: "2026-08-13 07:26:15".into(),
1751            duration_ms: 133_800,
1752            host: "openrouter.ai".into(),
1753            base_url: "https://openrouter.ai/api/v1".into(),
1754            protocol: Protocol::OpenAI,
1755            model: "gpt-5.6-sol".into(),
1756            claimed_model: "gpt-5.6-sol".into(),
1757            depth: "balanced".into(),
1758            seed: 0,
1759            steps: vec![],
1760            request_count: 47,
1761            results,
1762            verdict: Verdict {
1763                authenticity: Authenticity::ThirdParty,
1764                channel: Channel::Proxy,
1765                score: 92.0,
1766                confidence: 0.86,
1767                hard_gate_hits: vec![],
1768                signals: vec!["No vendor headers on any response".into()],
1769                trace: vec![
1770                    "contract layer clean, identity confidence kept at full weight".into(),
1771                    "two relay signatures → origin = relay".into(),
1772                    "weighted score 92.0, no hard gate → relayed".into(),
1773                ],
1774                group_scores,
1775                coverage_gap: 0.05,
1776            },
1777            identity: Identity {
1778                claimed_model: "gpt-5.6-sol".into(),
1779                claimed_family: Some("gpt".into()),
1780                claimed_tier: Some("large".into()),
1781                observed_family: Some("gpt".into()),
1782                family_confidence: 0.78,
1783                estimated_tier: Some("large".into()),
1784                tier_confidence: 0.82,
1785                tier_severity: 0,
1786                status: IdentityStatus::Match,
1787                evidence: vec!["Self-identification consistent across 4 phrasings".into()],
1788                tier_scores,
1789                accuracy_by_difficulty: acc,
1790                tier_questions: 9,
1791                tier_margin: 0.21,
1792            },
1793            billing: BillingAudit {
1794                rounds: vec![],
1795                method: "local estimate".into(),
1796                billed_input: 1_284,
1797                billed_output: 902,
1798                honest_input: 1_211,
1799                honest_output: 902,
1800                input_ratio: 1.06,
1801                billed_cost_usd: 0.014_2,
1802                honest_cost_usd: 0.013_4,
1803                cost_ratio: 1.06,
1804                pricing_source: "built-in price table".into(),
1805                anomalies: vec![],
1806            },
1807            channel: ChannelSignature {
1808                key: "proxy".into(),
1809                display: "openrouter".into(),
1810                confidence: 0.91,
1811                tier: 1,
1812                evidence: vec!["x-openrouter-* headers present".into()],
1813                all_hops: vec!["cloudflare".into(), "openrouter".into()],
1814            },
1815            perf: PerfSummary {
1816                samples: 12,
1817                ttft_ms: vec![
1818                    820.0, 910.0, 780.0, 1_140.0, 860.0, 930.0, 1_020.0, 2_400.0, 870.0, 890.0,
1819                    950.0, 1_010.0,
1820                ],
1821                latency_ms: vec![
1822                    3_100.0, 3_400.0, 2_900.0, 4_200.0, 3_200.0, 3_500.0, 3_800.0, 7_100.0,
1823                    3_150.0, 3_300.0, 3_600.0, 3_750.0,
1824                ],
1825                tps: vec![48.0, 51.0, 46.0],
1826                ttft_p50: 915.0,
1827                ttft_p95: 2_180.0,
1828                latency_p50: 3_450.0,
1829                latency_p95: 6_400.0,
1830                tps_mean: 48.3,
1831                latency_cv: 0.31,
1832            },
1833            skipped: vec!["consistency.seed — endpoint does not expose a seed".into()],
1834        }
1835    }
1836
1837    #[test]
1838    fn renders_a_complete_document() {
1839        let out = render(&sample(Lang::En));
1840        assert!(out.starts_with("<!doctype html>"));
1841        assert!(out.trim_end().ends_with("</html>"));
1842        assert!(out.contains(r#"<html lang="en">"#));
1843        assert!(out.contains("<title>llm-verify · openrouter.ai</title>"));
1844    }
1845
1846    #[test]
1847    fn every_index_entry_has_a_section_to_land_on() {
1848        // The sticky index and the sections below are generated separately;
1849        // a dead anchor would be invisible until someone clicked it.
1850        for lang in [Lang::En, Lang::Zh] {
1851            let rep = sample(lang);
1852            let out = render(&rep);
1853            for (id, label) in sections(&rep) {
1854                assert!(
1855                    out.contains(&format!(r##"href="#{id}""##)),
1856                    "{id} missing from the index"
1857                );
1858                assert!(
1859                    out.contains(&format!(r#"<section id="{id}""#)),
1860                    "{id} has an index entry but no section"
1861                );
1862                assert!(!label.is_empty());
1863            }
1864        }
1865    }
1866
1867    #[test]
1868    fn a_run_without_performance_samples_drops_the_section_and_its_anchor() {
1869        let mut rep = sample(Lang::En);
1870        rep.perf = PerfSummary::default();
1871        let out = render(&rep);
1872        assert!(!out.contains(r#"<section id="perf""#));
1873        assert!(!out.contains(r##"href="#perf""##));
1874    }
1875
1876    #[test]
1877    fn hard_gates_are_rendered_above_everything_else() {
1878        let mut rep = sample(Lang::En);
1879        rep.verdict.hard_gate_hits = vec![GateHit {
1880            name: "silent fallback".into(),
1881            probe: "identity.echo".into(),
1882            reason: "the echoed model differs from the requested one".into(),
1883        }];
1884        let out = render(&rep);
1885        let gate = out.find("silent fallback").expect("gate must render");
1886        let first_section = out.find("<section").expect("sections must render");
1887        assert!(
1888            gate < first_section,
1889            "the gate alert belongs above the sections"
1890        );
1891    }
1892
1893    #[test]
1894    fn hostile_strings_cannot_break_out_of_the_document() {
1895        let mut rep = sample(Lang::En);
1896        rep.host = "<script>alert(1)</script>".into();
1897        rep.results[0].summary = "</td></tr><script>x</script>".into();
1898        let out = render(&rep);
1899        assert!(!out.contains("<script>alert(1)</script>"));
1900        assert!(!out.contains("<script>x</script>"));
1901        assert!(out.contains("&lt;script&gt;"));
1902    }
1903
1904    #[test]
1905    fn the_probe_table_carries_a_per_group_tally() {
1906        let out = render(&sample(Lang::En));
1907        // One pass out of one probe in the contract group.
1908        assert!(out.contains("1/1 passed"));
1909    }
1910
1911    /// Not a test — a way to eyeball the design. Run with:
1912    /// `cargo test --release preview -- --ignored --nocapture`
1913    #[test]
1914    #[ignore]
1915    fn preview() {
1916        for (lang, name) in [(Lang::En, "preview-en.html"), (Lang::Zh, "preview-zh.html")] {
1917            let path = std::path::Path::new("target").join(name);
1918            std::fs::write(&path, render(&sample(lang))).unwrap();
1919            println!("wrote {}", path.display());
1920        }
1921    }
1922}