1use 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
390fn 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
401fn 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
412const 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
426fn 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
485fn 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
515fn 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 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
618fn 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 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 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 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 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 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 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 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 let _ = write!(
1267 h,
1268 r#"<div class="dist-mark" style="bottom:{mark:.1}%"><span>P50 {p50:.0}{unit}</span></div>"#,
1269 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 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 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 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("<script>"));
1902 }
1903
1904 #[test]
1905 fn the_probe_table_carries_a_per_group_tally() {
1906 let out = render(&sample(Lang::En));
1907 assert!(out.contains("1/1 passed"));
1909 }
1910
1911 #[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}